feat: add SDLC orchestration - library, CLI, and API integration

Implements deterministic feature lifecycle management for agent-driven
development. Agents use the CLI in pods; operators control via REST API.

Library (internal/sdlc/):
- Feature lifecycle with 10 phases (draft → released)
- Classifier engine with priority-ordered rules
- Artifact tracking with approval workflow
- Task management within features
- YAML-based state persistence

CLI (cmd/sdlc/):
- init, state, next, feature, artifact, task, query commands
- --json flag for machine-readable output
- Runs inside project pods

API (21 endpoints under /projects/{id}/sdlc/):
- State: GET /state, GET /next
- Features: CRUD + transition/block/unblock
- Artifacts: approve/reject per type
- Tasks: add/start/complete/block
- Queries: blocked/ready/needs-approval

Architecture:
- Port: SDLCExecutor interface (internal/port/)
- Adapter: kubectl exec into pods (internal/adapter/kubernetes/)
- Service: pod resolution + logging (internal/service/)
- Handlers: 5 files under 500-line limit (internal/handlers/)

Also includes template upgrades (chassis framework, UI components,
OpenAPI helpers, backend/frontend guides) and component improvements.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jordan 2026-02-02 09:57:05 -07:00
parent 62460bf098
commit 425ef0f806
74 changed files with 8712 additions and 55 deletions

1
.gitignore vendored
View File

@ -26,6 +26,7 @@ Thumbs.db
*.tar
*.gz
/rdev-api
/sdlc
coverage.out
# Temporary files

View File

@ -33,6 +33,7 @@ Run Claude Code instances in isolated Kubernetes pods with REST API control. Ena
| **Redis operations** | [services/redis.md](.claude/guides/services/redis.md) |
| **DNS / Cloudflare** | [services/dns-cloudflare.md](.claude/guides/services/dns-cloudflare.md) |
| **Network policies / internal routing** | [ops/networking.md](.claude/guides/ops/networking.md) |
| **SDLC orchestration** | [services/sdlc.md](.claude/guides/services/sdlc.md) |
## Critical Rules
@ -108,7 +109,9 @@ curl -H "X-API-Key: $RDEV_API_KEY" $RDEV_API_URL/work/stats
```
cmd/rdev-api/ # Entry point, DI, OpenAPI spec
cmd/sdlc/ # SDLC CLI binary (runs inside project pods)
internal/
├── sdlc/ # SDLC library (types, classifier, state I/O)
├── domain/ # Pure business models (no deps)
├── port/ # Interface contracts
├── service/ # Business logic orchestration
@ -163,6 +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) |
| Composable Monorepo Templates | **Done** | Monorepo skeleton + component templates (service, worker, app-astro, app-react, cli) |
**Current Version:** v0.10.25

View File

@ -22,6 +22,8 @@ Quick reference for rdev concepts and facts.
| Infrastructure Management | [features/infrastructure.md](./features/infrastructure.md) | High | 2025-01 | Gitea, Cloudflare, deployment |
| Build Orchestration | [features/build-orchestration.md](./features/build-orchestration.md) | High | 2026-01 | Bot-driven build specs with audit trail |
| Composable Monorepo | [features/composable-monorepo.md](./features/composable-monorepo.md) | High | 2026-01 | Monorepo skeleton + component templates |
| **SDLC** |
| SDLC Orchestration | [services/sdlc.md](./services/sdlc.md) | High | 2026-02 | Feature lifecycle, classifier engine, rdev API integration |
## Roadmap Reference

View File

@ -0,0 +1,88 @@
# SDLC Orchestration
**Last Updated:** 2026-02
**Confidence:** High (Steps 1-5 implemented, Step 6 pending)
## 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
- 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/`
**File Pointers:**
- Library: `internal/sdlc/` (types, state, feature, classifier, rules, config)
- 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`
- Spec: `docs/specs/sdlc-orchestration-system.md`
- Guide: `.claude/guides/services/sdlc.md`
## Library Types
```go
// internal/sdlc/classifier.go
type Classification struct {
Feature string `json:"feature"`
CurrentPhase FeaturePhase `json:"current_phase"`
RuleMatched string `json:"rule_matched"`
Action ActionType `json:"action"`
Message string `json:"message"`
NextCommand string `json:"next_command,omitempty"`
OutputPath string `json:"output_path,omitempty"`
TransitionTo FeaturePhase `json:"transition_to,omitempty"`
TaskID string `json:"task_id,omitempty"`
}
```
## CLI Commands
```
sdlc init # Create .sdlc/ structure
sdlc state [--json] # Full state dump
sdlc feature create <slug> <title> # New feature
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 query blocked/ready/needs-approval # Queries
```
## rdev API Endpoints (Planned - Step 6)
```
GET /projects/{id}/sdlc/state
GET /projects/{id}/sdlc/next
GET /projects/{id}/sdlc/features
GET /projects/{id}/sdlc/features/{slug}
POST /projects/{id}/sdlc/features/{slug}/transition
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/query/blocked
GET /projects/{id}/sdlc/query/ready
GET /projects/{id}/sdlc/query/needs-approval
```
## Feature Gaps (Step 6)
| 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 |
## Related Topics
- [Kubernetes Adapter](./kubernetes.md) - Pod execution pattern (PodGitOperations)
- [Work Queue](./work-queue.md) - Task execution for agents
- [Worker Pool](./worker-pool.md) - Agent pool management

View File

@ -241,6 +241,10 @@ func main() {
// Create build service (orchestrates build submission and tracking)
buildService := service.NewBuildService(workQueueRepo, buildAuditRepo, logger)
// SDLC lifecycle management (kubectl exec into project pods)
sdlcExec := kubernetes.NewSDLCExecutor(kubernetes.SDLCExecutorConfig{Namespace: namespace, Logger: logger})
sdlcService := service.NewSDLCService(sdlcExec, projectRepo, service.SDLCServiceConfig{Logger: logger})
// Create app
app := api.New("rdev-api",
api.WithPort(cfg.Port),
@ -368,6 +372,8 @@ func main() {
buildsHandler := handlers.NewBuildsHandler(buildService)
createAndBuildHandler := handlers.NewCreateAndBuildHandler(projectInfraService, buildService, logger)
sdlcHandler := handlers.NewSDLCHandler(sdlcService, logger)
// Initialize operations handler (for debugging project failures)
operationsHandler := handlers.NewOperationsHandler(operationRepo)
@ -398,6 +404,7 @@ func main() {
buildsHandler.Mount(app.Router())
createAndBuildHandler.Mount(app.Router())
operationsHandler.Mount(app.Router())
sdlcHandler.Mount(app.Router())
// Start queue processor worker (per-project command queue)
queueProcessor := worker.NewQueueProcessor(
@ -415,9 +422,7 @@ func main() {
os.Exit(1)
}
// Start work executor (cross-project worker pool)
// PodGitOperations runs git commands inside the pod via kubectl exec.
// This ensures deterministic post-build commit/push instead of relying on LLMs.
// Start work executor (cross-project worker pool, git via kubectl exec)
var podGitOps *worker.PodGitOperations
if infraCfg.GiteaToken != "" {
podGitOps = worker.NewPodGitOperations(worker.PodGitOperationsConfig{

195
cmd/sdlc/cmd_artifact.go Normal file
View File

@ -0,0 +1,195 @@
package main
import (
"fmt"
"os"
"github.com/orchard9/rdev/internal/sdlc"
"github.com/spf13/cobra"
)
var artifactCmd = &cobra.Command{
Use: "artifact",
Short: "Manage feature artifacts",
}
var artifactCreateCmd = &cobra.Command{
Use: "create <feature> <type>",
Short: "Create an artifact file (sets status to draft)",
Args: cobra.ExactArgs(2),
RunE: func(_ *cobra.Command, args []string) error {
root := mustResolveRoot()
slug, artTypeStr := args[0], args[1]
artType := sdlc.ArtifactType(artTypeStr)
if !sdlc.IsValidArtifactType(artType) {
return fmt.Errorf("invalid artifact type: %s (valid: spec, design, tasks, qa_plan, review, audit, qa_results)", artTypeStr)
}
f, err := sdlc.LoadFeature(root, slug)
if err != nil {
return err
}
// Create the artifact file if it doesn't exist
path := sdlc.ArtifactPath(root, slug, artType)
if _, err := os.Stat(path); os.IsNotExist(err) {
if err := os.WriteFile(path, fmt.Appendf(nil, "# %s: %s\n\n", artType, f.Title), 0o644); err != nil {
return fmt.Errorf("create artifact file: %w", err)
}
}
// Update manifest
art := f.GetArtifact(artType)
if art == nil {
art = sdlc.NewArtifact(artType)
f.SetArtifact(artType, art)
}
art.MarkDraft()
if err := f.Save(root); err != nil {
return err
}
if jsonOutput {
printJSON(map[string]string{
"feature": slug,
"artifact": string(artType),
"status": string(art.Status),
"path": path,
})
return nil
}
fmt.Printf("Created artifact: %s/%s\n", slug, artType)
fmt.Printf(" Status: %s\n", art.Status)
fmt.Printf(" Path: %s\n", path)
return nil
},
}
var artifactApproveCmd = &cobra.Command{
Use: "approve <feature> <type>",
Short: "Approve an artifact",
Args: cobra.ExactArgs(2),
RunE: func(_ *cobra.Command, args []string) error {
root := mustResolveRoot()
slug, artTypeStr := args[0], args[1]
artType := sdlc.ArtifactType(artTypeStr)
f, err := sdlc.LoadFeature(root, slug)
if err != nil {
return err
}
art := f.GetArtifact(artType)
if art == nil {
return sdlc.ErrArtifactNotFound
}
art.Approve("user")
if err := f.Save(root); err != nil {
return err
}
// Record in state
state, err := sdlc.LoadState(root)
if err != nil {
return err
}
state.RecordAction("APPROVE_ARTIFACT", slug, "user")
if err := state.Save(root); err != nil {
return err
}
if jsonOutput {
printJSON(map[string]string{
"feature": slug,
"artifact": string(artType),
"status": "approved",
})
return nil
}
fmt.Printf("Approved: %s/%s\n", slug, artType)
return nil
},
}
var artifactRejectCmd = &cobra.Command{
Use: "reject <feature> <type>",
Short: "Reject an artifact",
Args: cobra.ExactArgs(2),
RunE: func(_ *cobra.Command, args []string) error {
root := mustResolveRoot()
slug, artTypeStr := args[0], args[1]
artType := sdlc.ArtifactType(artTypeStr)
f, err := sdlc.LoadFeature(root, slug)
if err != nil {
return err
}
art := f.GetArtifact(artType)
if art == nil {
return sdlc.ErrArtifactNotFound
}
art.Reject("user")
if err := f.Save(root); err != nil {
return err
}
if jsonOutput {
printJSON(map[string]string{
"feature": slug,
"artifact": string(artType),
"status": "rejected",
})
return nil
}
fmt.Printf("Rejected: %s/%s\n", slug, artType)
return nil
},
}
var artifactStatusCmd = &cobra.Command{
Use: "status <feature>",
Short: "Show all artifact statuses for a feature",
Args: cobra.ExactArgs(1),
RunE: func(_ *cobra.Command, args []string) error {
root := mustResolveRoot()
f, err := sdlc.LoadFeature(root, args[0])
if err != nil {
return err
}
if jsonOutput {
printJSON(f.Artifacts)
return nil
}
fmt.Printf("Artifacts for %s:\n", f.Slug)
for _, at := range sdlc.ValidArtifactTypes {
art := f.GetArtifact(at)
if art == nil {
fmt.Printf(" %-12s -\n", at)
continue
}
fmt.Printf(" %-12s %s\n", at, art.Status)
}
return nil
},
}
func init() {
artifactCmd.AddCommand(
artifactCreateCmd,
artifactApproveCmd,
artifactRejectCmd,
artifactStatusCmd,
)
rootCmd.AddCommand(artifactCmd)
}

104
cmd/sdlc/cmd_config.go Normal file
View File

@ -0,0 +1,104 @@
package main
import (
"fmt"
"github.com/orchard9/rdev/internal/sdlc"
"github.com/spf13/cobra"
)
var configCmd = &cobra.Command{
Use: "config",
Short: "Manage SDLC configuration",
}
var configShowCmd = &cobra.Command{
Use: "show",
Short: "Show current configuration",
RunE: func(_ *cobra.Command, _ []string) error {
root := mustResolveRoot()
cfg, err := sdlc.LoadConfig(root)
if err != nil {
return err
}
if jsonOutput {
printJSON(cfg)
return nil
}
fmt.Printf("SDLC Config (v%d)\n", cfg.Version)
fmt.Printf(" Project: %s\n", cfg.Project.Name)
if cfg.Project.Type != "" {
fmt.Printf(" Type: %s\n", cfg.Project.Type)
}
fmt.Printf(" Main: %s\n", cfg.Branches.Main)
fmt.Printf(" Prefix: %s\n", cfg.Branches.FeaturePrefix)
fmt.Println()
fmt.Println("Enabled Phases:")
for _, p := range cfg.Phases.Enabled {
fmt.Printf(" - %s\n", p)
}
fmt.Println()
fmt.Println("Compliance:")
fmt.Printf(" Require Approvals: %v\n", cfg.Compliance.RequireApprovals)
fmt.Printf(" Require Branch: %v\n", cfg.Compliance.RequireBranch)
fmt.Printf(" Require QA: %v\n", cfg.Compliance.RequireQA)
return nil
},
}
var configSetCmd = &cobra.Command{
Use: "set <key> <value>",
Short: "Set a configuration value",
Args: cobra.ExactArgs(2),
RunE: func(_ *cobra.Command, args []string) error {
root := mustResolveRoot()
cfg, err := sdlc.LoadConfig(root)
if err != nil {
return err
}
key, value := args[0], args[1]
switch key {
case "project.name":
cfg.Project.Name = value
case "project.type":
cfg.Project.Type = value
case "branches.main":
cfg.Branches.Main = value
case "branches.feature_prefix":
cfg.Branches.FeaturePrefix = value
case "compliance.require_approvals":
cfg.Compliance.RequireApprovals = value == "true"
case "compliance.require_branch":
cfg.Compliance.RequireBranch = value == "true"
case "compliance.require_qa":
cfg.Compliance.RequireQA = value == "true"
default:
return fmt.Errorf("unknown config key: %s", key)
}
if err := cfg.Save(root); err != nil {
return err
}
if jsonOutput {
printJSON(map[string]string{"key": key, "value": value, "status": "set"})
return nil
}
fmt.Printf("Set %s = %s\n", key, value)
return nil
},
}
func init() {
configCmd.AddCommand(configShowCmd, configSetCmd)
rootCmd.AddCommand(configCmd)
}

321
cmd/sdlc/cmd_feature.go Normal file
View File

@ -0,0 +1,321 @@
package main
import (
"fmt"
"github.com/orchard9/rdev/internal/sdlc"
"github.com/spf13/cobra"
)
var featureTitle string
var featureCmd = &cobra.Command{
Use: "feature",
Short: "Manage features",
}
var featureCreateCmd = &cobra.Command{
Use: "create <slug>",
Short: "Create a new feature",
Args: cobra.ExactArgs(1),
RunE: func(_ *cobra.Command, args []string) error {
root := mustResolveRoot()
slug := args[0]
title := featureTitle
if title == "" {
title = slug
}
f, err := sdlc.CreateFeature(root, slug, title)
if err != nil {
return err
}
// Add to active work in state
state, err := sdlc.LoadState(root)
if err != nil {
return err
}
state.AddActiveFeature(slug, sdlc.PhaseDraft)
state.RecordAction("CREATE_FEATURE", slug, "cli")
if err := state.Save(root); err != nil {
return err
}
if jsonOutput {
printJSON(f)
return nil
}
fmt.Printf("Created feature: %s\n", slug)
fmt.Printf(" Title: %s\n", f.Title)
fmt.Printf(" Phase: %s\n", f.Phase)
fmt.Printf(" Path: .sdlc/features/%s/\n", slug)
fmt.Println()
fmt.Printf("Next: sdlc next --for %s\n", slug)
return nil
},
}
var featureListCmd = &cobra.Command{
Use: "list",
Short: "List all features",
RunE: func(_ *cobra.Command, _ []string) error {
root := mustResolveRoot()
features, err := sdlc.ListFeatures(root)
if err != nil {
return err
}
if jsonOutput {
printJSON(features)
return nil
}
if len(features) == 0 {
fmt.Println("No features found.")
fmt.Println("Create one: sdlc feature create <slug> --title \"Feature Name\"")
return nil
}
fmt.Println("Features:")
for _, f := range features {
summary := sdlc.SummarizeTasks(f.Tasks)
taskInfo := ""
if summary.Total > 0 {
taskInfo = fmt.Sprintf(" (%d/%d tasks)", summary.Completed, summary.Total)
}
blocked := ""
if f.IsBlocked() {
blocked = " [BLOCKED]"
}
fmt.Printf(" %-20s [%-15s]%s%s\n", f.Slug, f.Phase, taskInfo, blocked)
}
return nil
},
}
var featureShowCmd = &cobra.Command{
Use: "show <slug>",
Short: "Show feature details",
Args: cobra.ExactArgs(1),
RunE: func(_ *cobra.Command, args []string) error {
root := mustResolveRoot()
f, err := sdlc.LoadFeature(root, args[0])
if err != nil {
return err
}
if jsonOutput {
printJSON(f)
return nil
}
fmt.Printf("Feature: %s\n", f.Slug)
fmt.Printf(" Title: %s\n", f.Title)
fmt.Printf(" Phase: %s\n", f.Phase)
fmt.Printf(" Created: %s\n", f.Created.Format("2006-01-02 15:04:05"))
if f.Branch != "" {
fmt.Printf(" Branch: %s\n", f.Branch)
}
fmt.Println()
fmt.Println("Artifacts:")
for _, at := range sdlc.ValidArtifactTypes {
art := f.GetArtifact(at)
if art == nil {
continue
}
fmt.Printf(" %-12s %s\n", at, art.Status)
}
fmt.Println()
if len(f.Tasks) > 0 {
summary := sdlc.SummarizeTasks(f.Tasks)
fmt.Printf("Tasks: %d/%d complete", summary.Completed, summary.Total)
if summary.InProgress > 0 {
fmt.Printf(", %d in-progress", summary.InProgress)
}
if summary.Blocked > 0 {
fmt.Printf(", %d blocked", summary.Blocked)
}
fmt.Println()
}
if f.IsBlocked() {
fmt.Println()
fmt.Println("Blockers:")
for _, b := range f.Blockers {
fmt.Printf(" - %s\n", b)
}
}
return nil
},
}
var featureStatusCmd = &cobra.Command{
Use: "status <slug>",
Short: "Show feature phase and progress",
Args: cobra.ExactArgs(1),
RunE: func(_ *cobra.Command, args []string) error {
root := mustResolveRoot()
f, err := sdlc.LoadFeature(root, args[0])
if err != nil {
return err
}
if jsonOutput {
result := map[string]any{
"slug": f.Slug,
"phase": f.Phase,
"blocked": f.IsBlocked(),
"tasks": sdlc.SummarizeTasks(f.Tasks),
"blockers": f.Blockers,
}
printJSON(result)
return nil
}
fmt.Printf("Feature: %s\n", f.Slug)
fmt.Printf("Phase: %s\n", f.Phase)
if f.IsBlocked() {
fmt.Println("Status: BLOCKED")
}
if len(f.Tasks) > 0 {
s := sdlc.SummarizeTasks(f.Tasks)
fmt.Printf("Tasks: %d/%d complete\n", s.Completed, s.Total)
}
return nil
},
}
var featureTransitionCmd = &cobra.Command{
Use: "transition <slug> <phase>",
Short: "Manually transition feature to a phase",
Args: cobra.ExactArgs(2),
RunE: func(_ *cobra.Command, args []string) error {
root := mustResolveRoot()
slug, phase := args[0], sdlc.FeaturePhase(args[1])
f, err := sdlc.LoadFeature(root, slug)
if err != nil {
return err
}
cfg, err := sdlc.LoadConfig(root)
if err != nil {
return err
}
if err := f.CanTransitionTo(phase, cfg); err != nil {
return err
}
from := f.Phase
if err := f.Transition(phase); err != nil {
return err
}
if err := f.Save(root); err != nil {
return err
}
// Update state
state, err := sdlc.LoadState(root)
if err != nil {
return err
}
state.UpdateActiveFeature(slug, phase, f.Branch)
state.RecordAction("TRANSITION", slug, "cli")
if err := state.Save(root); err != nil {
return err
}
if jsonOutput {
printJSON(map[string]string{
"slug": slug,
"from": string(from),
"to": string(phase),
})
return nil
}
fmt.Printf("Transitioned %s: %s -> %s\n", slug, from, phase)
return nil
},
}
var featureBlockCmd = &cobra.Command{
Use: "block <slug> <reason>",
Short: "Mark feature as blocked",
Args: cobra.ExactArgs(2),
RunE: func(_ *cobra.Command, args []string) error {
root := mustResolveRoot()
slug, reason := args[0], args[1]
f, err := sdlc.LoadFeature(root, slug)
if err != nil {
return err
}
f.AddBlocker(reason)
if err := f.Save(root); err != nil {
return err
}
if jsonOutput {
printJSON(map[string]string{"slug": slug, "blocker": reason})
return nil
}
fmt.Printf("Blocked %s: %s\n", slug, reason)
return nil
},
}
var featureUnblockCmd = &cobra.Command{
Use: "unblock <slug>",
Short: "Remove all blockers from feature",
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
}
f.ClearBlockers()
if err := f.Save(root); err != nil {
return err
}
if jsonOutput {
printJSON(map[string]string{"slug": slug, "status": "unblocked"})
return nil
}
fmt.Printf("Unblocked %s\n", slug)
return nil
},
}
func init() {
featureCreateCmd.Flags().StringVar(&featureTitle, "title", "", "feature title")
featureCmd.AddCommand(
featureCreateCmd,
featureListCmd,
featureShowCmd,
featureStatusCmd,
featureTransitionCmd,
featureBlockCmd,
featureUnblockCmd,
)
rootCmd.AddCommand(featureCmd)
}

54
cmd/sdlc/cmd_init.go Normal file
View File

@ -0,0 +1,54 @@
package main
import (
"fmt"
"path/filepath"
"github.com/orchard9/rdev/internal/sdlc"
"github.com/spf13/cobra"
)
var initProjectName string
var initCmd = &cobra.Command{
Use: "init",
Short: "Initialize .sdlc/ directory structure",
RunE: func(_ *cobra.Command, _ []string) error {
root := mustResolveRoot()
name := initProjectName
if name == "" {
name = filepath.Base(root)
}
if err := sdlc.Init(root, name); err != nil {
return err
}
if jsonOutput {
printJSON(map[string]string{
"status": "initialized",
"root": root,
"project": name,
})
return nil
}
fmt.Println("Initialized .sdlc/ structure")
fmt.Printf(" Root: %s\n", root)
fmt.Printf(" Project: %s\n", name)
fmt.Println()
fmt.Println("Created directories:")
for _, dir := range sdlc.SubDirs() {
fmt.Printf(" .sdlc/%s/\n", dir)
}
fmt.Println()
fmt.Println("Next: sdlc feature create <slug> --title \"Feature Name\"")
return nil
},
}
func init() {
initCmd.Flags().StringVar(&initProjectName, "name", "", "project name (default: directory name)")
rootCmd.AddCommand(initCmd)
}

137
cmd/sdlc/cmd_next.go Normal file
View File

@ -0,0 +1,137 @@
package main
import (
"fmt"
"github.com/orchard9/rdev/internal/sdlc"
"github.com/spf13/cobra"
)
var (
nextForFeature string
)
var nextCmd = &cobra.Command{
Use: "next",
Short: "Run classifier and show next required action",
RunE: func(_ *cobra.Command, _ []string) error {
root := mustResolveRoot()
state, err := sdlc.LoadState(root)
if err != nil {
return err
}
cfg, err := sdlc.LoadConfig(root)
if err != nil {
return err
}
classifier := sdlc.NewClassifier()
// If a specific feature is requested
if nextForFeature != "" {
return classifyFeature(root, state, cfg, classifier, nextForFeature)
}
// Classify all active features, return first actionable
if len(state.ActiveWork.Features) == 0 {
if jsonOutput {
printJSON(map[string]string{"action": "IDLE", "message": "No active features"})
return nil
}
fmt.Println("No active features. Create one: sdlc feature create <slug>")
return nil
}
for _, af := range state.ActiveWork.Features {
f, err := sdlc.LoadFeature(root, af.Slug)
if err != nil {
continue
}
cl := classifier.Classify(&sdlc.EvalContext{
State: state,
Feature: f,
Config: cfg,
Root: root,
})
if cl.Action != sdlc.ActionIdle {
return printClassification(cl, f)
}
}
if jsonOutput {
printJSON(map[string]string{"action": "IDLE", "message": "No actionable work found"})
return nil
}
fmt.Println("No actionable work found across active features.")
return nil
},
}
func classifyFeature(root string, state *sdlc.State, cfg *sdlc.Config, classifier *sdlc.Classifier, slug string) error {
f, err := sdlc.LoadFeature(root, slug)
if err != nil {
return err
}
cl := classifier.Classify(&sdlc.EvalContext{
State: state,
Feature: f,
Config: cfg,
Root: root,
})
return printClassification(cl, f)
}
func printClassification(cl *sdlc.Classification, f *sdlc.Feature) error {
if jsonOutput {
printJSON(cl)
return nil
}
fmt.Printf("Feature: %s\n", cl.Feature)
fmt.Printf("Phase: %s\n", cl.CurrentPhase)
if len(f.Tasks) > 0 {
s := sdlc.SummarizeTasks(f.Tasks)
fmt.Printf("Tasks: %d/%d complete", s.Completed, s.Total)
if s.InProgress > 0 {
fmt.Printf(", %d in-progress", s.InProgress)
}
if s.Pending > 0 {
fmt.Printf(", %d pending", s.Pending)
}
fmt.Println()
}
fmt.Println()
fmt.Printf("NEXT ACTION: %s\n", cl.Action)
if cl.Message != "" {
fmt.Printf("Message: %s\n", cl.Message)
}
if cl.NextCommand != "" {
fmt.Printf("Command: %s\n", cl.NextCommand)
}
if cl.OutputPath != "" {
fmt.Printf("Output: %s\n", cl.OutputPath)
}
if cl.TransitionTo != "" {
fmt.Printf("Transition: -> %s\n", cl.TransitionTo)
}
if cl.TaskID != "" {
fmt.Printf("Task: %s\n", cl.TaskID)
}
fmt.Printf("Rule: %s\n", cl.RuleMatched)
return nil
}
func init() {
nextCmd.Flags().StringVar(&nextForFeature, "for", "", "classify specific feature")
rootCmd.AddCommand(nextCmd)
}

202
cmd/sdlc/cmd_query.go Normal file
View File

@ -0,0 +1,202 @@
package main
import (
"fmt"
"github.com/orchard9/rdev/internal/sdlc"
"github.com/spf13/cobra"
)
var queryCmd = &cobra.Command{
Use: "query",
Short: "Query SDLC state",
}
var queryBlockedCmd = &cobra.Command{
Use: "blocked",
Short: "List all blocked items",
RunE: func(_ *cobra.Command, _ []string) error {
root := mustResolveRoot()
features, err := sdlc.ListFeatures(root)
if err != nil {
return err
}
type blockedInfo struct {
Slug string `json:"slug"`
Phase string `json:"phase"`
Blockers []string `json:"blockers"`
}
var blocked []blockedInfo
for _, f := range features {
if f.IsBlocked() {
blocked = append(blocked, blockedInfo{
Slug: f.Slug,
Phase: string(f.Phase),
Blockers: f.Blockers,
})
}
}
if jsonOutput {
printJSON(blocked)
return nil
}
if len(blocked) == 0 {
fmt.Println("No blocked items.")
return nil
}
fmt.Println("Blocked Items:")
for _, b := range blocked {
fmt.Printf(" %s [%s]:\n", b.Slug, b.Phase)
for _, reason := range b.Blockers {
fmt.Printf(" - %s\n", reason)
}
}
return nil
},
}
var queryReadyCmd = &cobra.Command{
Use: "ready",
Short: "List items ready for work",
RunE: func(_ *cobra.Command, _ []string) error {
root := mustResolveRoot()
state, err := sdlc.LoadState(root)
if err != nil {
return err
}
cfg, err := sdlc.LoadConfig(root)
if err != nil {
return err
}
features, err := sdlc.ListFeatures(root)
if err != nil {
return err
}
classifier := sdlc.NewClassifier()
type readyInfo struct {
Slug string `json:"slug"`
Phase string `json:"phase"`
Action string `json:"action"`
}
var ready []readyInfo
for _, f := range features {
if f.IsBlocked() {
continue
}
cl := classifier.Classify(&sdlc.EvalContext{
State: state,
Feature: f,
Config: cfg,
Root: root,
})
if cl.Action != sdlc.ActionIdle && cl.Action != sdlc.ActionBlocked && cl.Action != sdlc.ActionAwaitApproval {
ready = append(ready, readyInfo{
Slug: f.Slug,
Phase: string(f.Phase),
Action: string(cl.Action),
})
}
}
if jsonOutput {
printJSON(ready)
return nil
}
if len(ready) == 0 {
fmt.Println("No items ready for work.")
return nil
}
fmt.Println("Ready for Work:")
for _, r := range ready {
fmt.Printf(" %-20s [%-15s] -> %s\n", r.Slug, r.Phase, r.Action)
}
return nil
},
}
var queryNeedsApprovalCmd = &cobra.Command{
Use: "needs-approval",
Short: "List items awaiting approval",
RunE: func(_ *cobra.Command, _ []string) error {
root := mustResolveRoot()
state, err := sdlc.LoadState(root)
if err != nil {
return err
}
cfg, err := sdlc.LoadConfig(root)
if err != nil {
return err
}
features, err := sdlc.ListFeatures(root)
if err != nil {
return err
}
classifier := sdlc.NewClassifier()
type approvalInfo struct {
Slug string `json:"slug"`
Phase string `json:"phase"`
Message string `json:"message"`
}
var pending []approvalInfo
for _, f := range features {
cl := classifier.Classify(&sdlc.EvalContext{
State: state,
Feature: f,
Config: cfg,
Root: root,
})
if cl.Action == sdlc.ActionAwaitApproval {
pending = append(pending, approvalInfo{
Slug: f.Slug,
Phase: string(f.Phase),
Message: cl.Message,
})
}
}
if jsonOutput {
printJSON(pending)
return nil
}
if len(pending) == 0 {
fmt.Println("No items awaiting approval.")
return nil
}
fmt.Println("Awaiting Approval:")
for _, p := range pending {
fmt.Printf(" %-20s [%-15s] %s\n", p.Slug, p.Phase, p.Message)
}
return nil
},
}
func init() {
queryCmd.AddCommand(
queryBlockedCmd,
queryReadyCmd,
queryNeedsApprovalCmd,
)
rootCmd.AddCommand(queryCmd)
}

66
cmd/sdlc/cmd_state.go Normal file
View File

@ -0,0 +1,66 @@
package main
import (
"fmt"
"github.com/orchard9/rdev/internal/sdlc"
"github.com/spf13/cobra"
)
var stateCmd = &cobra.Command{
Use: "state",
Short: "Show current SDLC state",
RunE: func(_ *cobra.Command, _ []string) error {
root := mustResolveRoot()
state, err := sdlc.LoadState(root)
if err != nil {
return err
}
if jsonOutput {
printJSON(state)
return nil
}
fmt.Printf("SDLC State (v%d)\n", state.Version)
fmt.Printf(" Project: %s\n", state.Project.Name)
if state.Project.CurrentRoadmap != "" {
fmt.Printf(" Roadmap: %s\n", state.Project.CurrentRoadmap)
}
fmt.Println()
if len(state.ActiveWork.Features) > 0 {
fmt.Println("Active Features:")
for _, f := range state.ActiveWork.Features {
branch := ""
if f.Branch != "" {
branch = fmt.Sprintf(" (%s)", f.Branch)
}
fmt.Printf(" - %s [%s]%s\n", f.Slug, f.Phase, branch)
}
fmt.Println()
}
if len(state.Blocked) > 0 {
fmt.Println("Blocked Items:")
for _, b := range state.Blocked {
fmt.Printf(" - %s/%s: %s\n", b.Type, b.Slug, b.Reason)
}
fmt.Println()
}
if state.LastAction != "" {
fmt.Printf("Last Action: %s by %s\n", state.LastAction, state.LastActor)
}
if state.LastUpdated != nil {
fmt.Printf("Last Updated: %s\n", state.LastUpdated.Format("2006-01-02 15:04:05"))
}
return nil
},
}
func init() {
rootCmd.AddCommand(stateCmd)
}

207
cmd/sdlc/cmd_task.go Normal file
View File

@ -0,0 +1,207 @@
package main
import (
"fmt"
"github.com/orchard9/rdev/internal/sdlc"
"github.com/spf13/cobra"
)
var taskCmd = &cobra.Command{
Use: "task",
Short: "Manage feature tasks",
}
var taskListCmd = &cobra.Command{
Use: "list <feature>",
Short: "List tasks for a feature",
Args: cobra.ExactArgs(1),
RunE: func(_ *cobra.Command, args []string) error {
root := mustResolveRoot()
f, err := sdlc.LoadFeature(root, args[0])
if err != nil {
return err
}
if jsonOutput {
printJSON(f.Tasks)
return nil
}
if len(f.Tasks) == 0 {
fmt.Println("No tasks defined.")
fmt.Printf("Add one: sdlc task add %s \"Task title\"\n", args[0])
return nil
}
summary := sdlc.SummarizeTasks(f.Tasks)
fmt.Printf("Tasks for %s (%d/%d complete):\n", f.Slug, summary.Completed, summary.Total)
for _, t := range f.Tasks {
icon := "○"
switch t.Status {
case sdlc.TaskComplete:
icon = "✓"
case sdlc.TaskInProgress:
icon = "→"
case sdlc.TaskBlocked:
icon = "✗"
}
fmt.Printf(" %s %s: %s [%s]\n", icon, t.ID, t.Title, t.Status)
}
return nil
},
}
var taskStartCmd = &cobra.Command{
Use: "start <feature> <task-id>",
Short: "Mark a task as in-progress",
Args: cobra.ExactArgs(2),
RunE: func(_ *cobra.Command, args []string) error {
root := mustResolveRoot()
slug, taskID := args[0], args[1]
f, err := sdlc.LoadFeature(root, slug)
if err != nil {
return err
}
f.Tasks, err = sdlc.StartTask(f.Tasks, taskID)
if err != nil {
return err
}
f.UpdateTaskSummary()
if err := f.Save(root); err != nil {
return err
}
if jsonOutput {
printJSON(map[string]string{"feature": slug, "task": taskID, "status": "in_progress"})
return nil
}
fmt.Printf("Started: %s/%s\n", slug, taskID)
return nil
},
}
var taskCompleteCmd = &cobra.Command{
Use: "complete <feature> <task-id>",
Short: "Mark a task as complete",
Args: cobra.ExactArgs(2),
RunE: func(_ *cobra.Command, args []string) error {
root := mustResolveRoot()
slug, taskID := args[0], args[1]
f, err := sdlc.LoadFeature(root, slug)
if err != nil {
return err
}
f.Tasks, err = sdlc.CompleteTask(f.Tasks, taskID)
if err != nil {
return err
}
f.UpdateTaskSummary()
if err := f.Save(root); err != nil {
return err
}
// Record in state
state, err := sdlc.LoadState(root)
if err != nil {
return err
}
state.RecordAction("COMPLETE_TASK", slug, "cli")
if err := state.Save(root); err != nil {
return err
}
if jsonOutput {
printJSON(map[string]string{"feature": slug, "task": taskID, "status": "complete"})
return nil
}
s := sdlc.SummarizeTasks(f.Tasks)
fmt.Printf("Completed: %s/%s (%d/%d tasks done)\n", slug, taskID, s.Completed, s.Total)
return nil
},
}
var taskBlockCmd = &cobra.Command{
Use: "block <feature> <task-id>",
Short: "Mark a task as blocked",
Args: cobra.ExactArgs(2),
RunE: func(_ *cobra.Command, args []string) error {
root := mustResolveRoot()
slug, taskID := args[0], args[1]
f, err := sdlc.LoadFeature(root, slug)
if err != nil {
return err
}
f.Tasks, err = sdlc.BlockTask(f.Tasks, taskID)
if err != nil {
return err
}
f.UpdateTaskSummary()
if err := f.Save(root); err != nil {
return err
}
if jsonOutput {
printJSON(map[string]string{"feature": slug, "task": taskID, "status": "blocked"})
return nil
}
fmt.Printf("Blocked: %s/%s\n", slug, taskID)
return nil
},
}
var taskAddCmd = &cobra.Command{
Use: "add <feature> <title>",
Short: "Add a new task",
Args: cobra.ExactArgs(2),
RunE: func(_ *cobra.Command, args []string) error {
root := mustResolveRoot()
slug, title := args[0], args[1]
f, err := sdlc.LoadFeature(root, slug)
if err != nil {
return err
}
f.Tasks = sdlc.AddTask(f.Tasks, title)
f.UpdateTaskSummary()
if err := f.Save(root); err != nil {
return err
}
newTask := f.Tasks[len(f.Tasks)-1]
if jsonOutput {
printJSON(newTask)
return nil
}
fmt.Printf("Added: %s/%s - %s\n", slug, newTask.ID, title)
return nil
},
}
func init() {
taskCmd.AddCommand(
taskListCmd,
taskStartCmd,
taskCompleteCmd,
taskBlockCmd,
taskAddCmd,
)
rootCmd.AddCommand(taskCmd)
}

13
cmd/sdlc/main.go Normal file
View File

@ -0,0 +1,13 @@
package main
import (
"fmt"
"os"
)
func main() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}

81
cmd/sdlc/root.go Normal file
View File

@ -0,0 +1,81 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/orchard9/rdev/internal/sdlc"
"github.com/spf13/cobra"
)
var (
rootDir string
jsonOutput bool
)
var rootCmd = &cobra.Command{
Use: "sdlc",
Short: "Deterministic SDLC orchestration tool",
Long: "Manage the software development lifecycle with deterministic state, artifacts, and classification.",
}
func init() {
rootCmd.PersistentFlags().StringVar(&rootDir, "root", "", "project root (default: auto-detect)")
rootCmd.PersistentFlags().BoolVar(&jsonOutput, "json", false, "output as JSON")
}
// resolveRoot finds the project root by looking for .sdlc/ or .git/ walking up.
func resolveRoot() (string, error) {
if rootDir != "" {
abs, err := filepath.Abs(rootDir)
if err != nil {
return "", fmt.Errorf("resolve root: %w", err)
}
return abs, nil
}
dir, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("get working directory: %w", err)
}
for {
if sdlc.IsInitialized(dir) {
return dir, nil
}
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
return dir, nil
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
// Fall back to cwd
cwd, _ := os.Getwd()
return cwd, nil
}
// mustResolveRoot resolves root or exits.
func mustResolveRoot() string {
root, err := resolveRoot()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
return root
}
// printJSON marshals v as indented JSON and prints to stdout.
func printJSON(v any) {
data, err := json.MarshalIndent(v, "", " ")
if err != nil {
fmt.Fprintf(os.Stderr, "Error marshaling JSON: %v\n", err)
os.Exit(1)
}
fmt.Println(string(data))
}

View File

@ -257,6 +257,24 @@ POST /project/landing/build
---
## Cookbooks
| Cookbook | Status | Description |
|---------|--------|-------------|
| [Landing Page](./landing-page.md) | Done | Simple single-component deployment |
| [Feature Development](./feature-development.md) | Done | Full-stack feature with chassis, OpenAPI, auth, design system |
## E2E Test Scripts
| Script | Description |
|--------|-------------|
| `scripts/landing-test.sh` | Landing page E2E test |
| `scripts/feature-test.sh` | Feature development E2E test |
| `scripts/composable-test.sh` | Composable monorepo E2E test |
| `scripts/template-validation.sh` | Template validation checks |
---
## Questions to Resolve
1. **Claudebox scaling strategy?**

View File

@ -764,6 +764,66 @@ Check that `AuthProvider` wraps your app in `providers.tsx`.
---
## Chassis Framework
The `pkg/chassis` package provides a convenience facade over `pkg/app`:
```go
import "my-project/pkg/chassis"
svc := chassis.New("my-service", chassis.WithDefaultPort(8080))
```
It re-exports: `New`, `Wrap`, `WrapWithLogger`, `Bind`, `BindAndValidate`, `BindStrict`, `NewHealthHandler`, `PingChecker`, `HTTPChecker`.
## OpenAPI Documentation
Each service defines its spec in `internal/api/spec.go`:
```go
spec := openapi.NewOpenAPISpec("My API", "1.0.0").
WithBearerSecurity("bearer", "JWT token")
spec.WithSchema("User", openapi.Object(map[string]openapi.Schema{
"id": openapi.UUID(),
"email": openapi.Email(),
}, "id", "email"))
spec.AddPath("/api/v1/users/{id}", "get", map[string]any{
"summary": "Get user",
"tags": []string{"Users"},
"parameters": []any{openapi.IDParam()},
"responses": map[string]any{
"200": openapi.OpResponse("Success", openapi.ResponseSchema(openapi.Ref("User"))),
"404": openapi.OpResponse("Not found", openapi.ErrorResponseSchema()),
},
})
application.EnableDocs(spec) // Mounts /docs (Scalar UI) and /openapi.json
```
## Design System Components
Available from `@project/ui`:
| Component | Usage |
|-----------|-------|
| Button | Primary actions, variants: default, destructive, outline, ghost |
| Card | Content containers with CardHeader, CardContent, CardFooter |
| Input, Label | Form fields |
| Badge | Status indicators, variants: success, warning, error, info |
| Dialog | Modal dialogs |
| Table | Data tables |
| Select | Dropdowns |
| Alert | Notification banners, variants: default, destructive, success, warning |
| Textarea | Multiline input |
| DropdownMenu | Context menus with items, checkboxes, radio groups |
| Sheet | Slide-in panels (side: top, right, bottom, left) |
All use CSS custom properties: `var(--background)`, `var(--accent)`, `var(--border)`, etc.
---
## Related
- [Composable App Cookbook](./composable-app.md) - Creating projects with components

View File

@ -97,6 +97,30 @@ verify_chassis_patterns() {
else
print_warning "pkg/auth/ not found"
fi
# Check pkg/chassis exists
echo "Checking pkg/chassis..."
local chassis_check
chassis_check=$(curl -s "https://git.threesix.ai/api/v1/repos/$git_owner/$project_id/contents/pkg/chassis/chassis.go" \
-H "Authorization: token ${GITEA_TOKEN:-}" 2>/dev/null | jq -r '.name // "not found"')
if [[ "$chassis_check" == "chassis.go" ]]; then
print_success "pkg/chassis/chassis.go exists (facade)"
else
print_warning "pkg/chassis/chassis.go not found"
fi
# Check pkg/openapi exists
echo "Checking pkg/openapi..."
local openapi_check
openapi_check=$(curl -s "https://git.threesix.ai/api/v1/repos/$git_owner/$project_id/contents/pkg/openapi" \
-H "Authorization: token ${GITEA_TOKEN:-}" 2>/dev/null | jq -r 'if type == "array" then "directory" else "not found" end')
if [[ "$openapi_check" == "directory" ]]; then
print_success "pkg/openapi/ directory exists (spec builder + docs)"
else
print_warning "pkg/openapi/ not found"
fi
}
# Verify design system packages
@ -187,6 +211,30 @@ verify_service_patterns() {
else
print_warning "routes.go not found"
fi
# Check services/api/internal/api/spec.go exists (OpenAPI)
echo "Checking spec.go (OpenAPI)..."
local spec_check
spec_check=$(curl -s "https://git.threesix.ai/api/v1/repos/$git_owner/$project_id/contents/services/api/internal/api/spec.go" \
-H "Authorization: token ${GITEA_TOKEN:-}" 2>/dev/null | jq -r '.name // "not found"')
if [[ "$spec_check" == "spec.go" ]]; then
print_success "services/api/internal/api/spec.go exists (OpenAPI spec)"
else
print_warning "spec.go not found"
fi
# Check services/api/internal/api/handlers/example_test.go exists
echo "Checking example_test.go..."
local test_check
test_check=$(curl -s "https://git.threesix.ai/api/v1/repos/$git_owner/$project_id/contents/services/api/internal/api/handlers/example_test.go" \
-H "Authorization: token ${GITEA_TOKEN:-}" 2>/dev/null | jq -r '.name // "not found"')
if [[ "$test_check" == "example_test.go" ]]; then
print_success "services/api/internal/api/handlers/example_test.go exists"
else
print_warning "example_test.go not found"
fi
}
# Verify app-nextjs component

4
go.mod
View File

@ -11,12 +11,14 @@ require (
github.com/lib/pq v1.10.9
github.com/prometheus/client_golang v1.23.2
github.com/redis/go-redis/v9 v9.17.3
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
go.opentelemetry.io/otel v1.39.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0
go.opentelemetry.io/otel/sdk v1.39.0
go.opentelemetry.io/otel/trace v1.39.0
go.woodpecker-ci.org/woodpecker/v3 v3.13.0
gopkg.in/yaml.v3 v3.0.1
k8s.io/api v0.35.0
k8s.io/apimachinery v0.35.0
k8s.io/client-go v0.35.0
@ -41,6 +43,7 @@ require (
github.com/google/gnostic-models v0.7.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
github.com/hashicorp/go-version v1.7.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
@ -72,7 +75,6 @@ require (
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect

6
go.sum
View File

@ -16,6 +16,7 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
@ -62,6 +63,8 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLW
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4=
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
@ -106,6 +109,9 @@ github.com/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1D
github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=

View File

@ -0,0 +1,288 @@
package kubernetes
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"os/exec"
"strings"
"github.com/orchard9/rdev/internal/port"
"github.com/orchard9/rdev/internal/sdlc"
)
// SDLCExecutor runs sdlc CLI commands inside pods via kubectl exec.
type SDLCExecutor struct {
namespace string
logger *slog.Logger
}
// SDLCExecutorConfig configures the SDLC executor.
type SDLCExecutorConfig struct {
Namespace string
Logger *slog.Logger
}
// NewSDLCExecutor creates a new SDLC executor.
func NewSDLCExecutor(cfg SDLCExecutorConfig) *SDLCExecutor {
logger := cfg.Logger
if logger == nil {
logger = slog.Default()
}
return &SDLCExecutor{
namespace: cfg.Namespace,
logger: logger.With("component", "sdlc-executor"),
}
}
// execSDLC runs a sdlc CLI command in the pod and returns stdout bytes.
// All commands include --json for machine-readable output.
func (e *SDLCExecutor) execSDLC(ctx context.Context, podName string, args ...string) ([]byte, error) {
kubectlArgs := []string{"exec", "-n", e.namespace, podName, "--", "sdlc"}
kubectlArgs = append(kubectlArgs, args...)
kubectlArgs = append(kubectlArgs, "--json")
cmd := exec.CommandContext(ctx, "kubectl", kubectlArgs...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return nil, e.mapExecError(stderr.String(), err)
}
return stdout.Bytes(), nil
}
// execSDLCNoOutput runs a sdlc CLI command that produces no meaningful stdout.
func (e *SDLCExecutor) execSDLCNoOutput(ctx context.Context, podName string, args ...string) error {
_, err := e.execSDLC(ctx, podName, args...)
return err
}
// mapExecError maps stderr text from the sdlc CLI to sentinel errors.
func (e *SDLCExecutor) mapExecError(stderr string, execErr error) error {
stderr = strings.TrimSpace(stderr)
switch {
case strings.Contains(stderr, "sdlc not initialized"):
return sdlc.ErrNotInitialized
case strings.Contains(stderr, "feature not found"):
return sdlc.ErrFeatureNotFound
case strings.Contains(stderr, "feature already exists"):
return sdlc.ErrFeatureExists
case strings.Contains(stderr, "invalid phase transition"):
return sdlc.ErrInvalidTransition
case strings.Contains(stderr, "invalid phase"):
return sdlc.ErrInvalidPhase
case strings.Contains(stderr, "task not found"):
return sdlc.ErrTaskNotFound
case strings.Contains(stderr, "artifact not found"):
return sdlc.ErrArtifactNotFound
case strings.Contains(stderr, "invalid slug"):
return sdlc.ErrInvalidSlug
case strings.Contains(stderr, "invalid artifact"):
return sdlc.ErrInvalidArtifact
default:
if stderr != "" {
return fmt.Errorf("sdlc exec: %s: %w", stderr, execErr)
}
return fmt.Errorf("sdlc exec: %w", execErr)
}
}
// GetState returns the global SDLC state for a project pod.
func (e *SDLCExecutor) GetState(ctx context.Context, podName string) (*sdlc.State, error) {
out, err := e.execSDLC(ctx, podName, "state")
if err != nil {
return nil, err
}
var state sdlc.State
if err := json.Unmarshal(out, &state); err != nil {
return nil, fmt.Errorf("parse sdlc state: %w", err)
}
return &state, nil
}
// GetNext returns the classifier's recommendation for the next action.
func (e *SDLCExecutor) GetNext(ctx context.Context, podName, feature string) (*sdlc.Classification, error) {
args := []string{"next"}
if feature != "" {
args = append(args, "--feature", feature)
}
out, err := e.execSDLC(ctx, podName, args...)
if err != nil {
return nil, err
}
var cl sdlc.Classification
if err := json.Unmarshal(out, &cl); err != nil {
return nil, fmt.Errorf("parse sdlc classification: %w", err)
}
return &cl, nil
}
// ListFeatures returns all features in the project.
func (e *SDLCExecutor) ListFeatures(ctx context.Context, podName string) ([]*sdlc.Feature, error) {
out, err := e.execSDLC(ctx, podName, "feature", "list")
if err != nil {
return nil, err
}
var features []*sdlc.Feature
if err := json.Unmarshal(out, &features); err != nil {
return nil, fmt.Errorf("parse sdlc features: %w", err)
}
return features, nil
}
// GetFeature returns a single feature by slug.
func (e *SDLCExecutor) GetFeature(ctx context.Context, podName, slug string) (*sdlc.Feature, error) {
out, err := e.execSDLC(ctx, podName, "feature", "show", slug)
if err != nil {
return nil, err
}
var f sdlc.Feature
if err := json.Unmarshal(out, &f); err != nil {
return nil, fmt.Errorf("parse sdlc feature: %w", err)
}
return &f, nil
}
// CreateFeature creates a new feature with the given slug and title.
func (e *SDLCExecutor) CreateFeature(ctx context.Context, podName, slug, title string) (*sdlc.Feature, error) {
out, err := e.execSDLC(ctx, podName, "feature", "create", slug, "--title", title)
if err != nil {
return nil, err
}
var f sdlc.Feature
if err := json.Unmarshal(out, &f); err != nil {
return nil, fmt.Errorf("parse sdlc feature: %w", err)
}
return &f, nil
}
// TransitionFeature moves a feature to the specified phase.
func (e *SDLCExecutor) TransitionFeature(ctx context.Context, podName, slug string, phase sdlc.FeaturePhase) error {
return e.execSDLCNoOutput(ctx, podName, "feature", "transition", slug, string(phase))
}
// BlockFeature adds a blocker reason to a feature.
func (e *SDLCExecutor) BlockFeature(ctx context.Context, podName, slug, reason string) error {
return e.execSDLCNoOutput(ctx, podName, "feature", "block", slug, "--reason", reason)
}
// UnblockFeature removes all blockers from a feature.
func (e *SDLCExecutor) UnblockFeature(ctx context.Context, podName, slug string) error {
return e.execSDLCNoOutput(ctx, podName, "feature", "unblock", slug)
}
// DeleteFeature removes a feature entirely.
func (e *SDLCExecutor) DeleteFeature(ctx context.Context, podName, slug string) error {
return e.execSDLCNoOutput(ctx, podName, "feature", "delete", slug, "--force")
}
// GetArtifactStatus returns artifact statuses for a feature.
func (e *SDLCExecutor) GetArtifactStatus(ctx context.Context, podName, slug string) (map[sdlc.ArtifactType]*sdlc.Artifact, error) {
out, err := e.execSDLC(ctx, podName, "artifact", "status", slug)
if err != nil {
return nil, err
}
var artifacts map[sdlc.ArtifactType]*sdlc.Artifact
if err := json.Unmarshal(out, &artifacts); err != nil {
return nil, fmt.Errorf("parse sdlc artifacts: %w", err)
}
return artifacts, nil
}
// ApproveArtifact approves a feature artifact.
func (e *SDLCExecutor) ApproveArtifact(ctx context.Context, podName, slug string, artType sdlc.ArtifactType) error {
return e.execSDLCNoOutput(ctx, podName, "artifact", "approve", slug, string(artType))
}
// RejectArtifact rejects a feature artifact.
func (e *SDLCExecutor) RejectArtifact(ctx context.Context, podName, slug string, artType sdlc.ArtifactType) error {
return e.execSDLCNoOutput(ctx, podName, "artifact", "reject", slug, string(artType))
}
// ListTasks returns all tasks for a feature.
func (e *SDLCExecutor) ListTasks(ctx context.Context, podName, slug string) ([]sdlc.Task, error) {
out, err := e.execSDLC(ctx, podName, "task", "list", slug)
if err != nil {
return nil, err
}
var tasks []sdlc.Task
if err := json.Unmarshal(out, &tasks); err != nil {
return nil, fmt.Errorf("parse sdlc tasks: %w", err)
}
return tasks, nil
}
// AddTask adds a new task to a feature.
func (e *SDLCExecutor) AddTask(ctx context.Context, podName, slug, title string) (*sdlc.Task, error) {
out, err := e.execSDLC(ctx, podName, "task", "add", slug, "--title", title)
if err != nil {
return nil, err
}
var t sdlc.Task
if err := json.Unmarshal(out, &t); err != nil {
return nil, fmt.Errorf("parse sdlc task: %w", err)
}
return &t, nil
}
// StartTask marks a task as in-progress.
func (e *SDLCExecutor) StartTask(ctx context.Context, podName, slug, taskID string) error {
return e.execSDLCNoOutput(ctx, podName, "task", "start", slug, taskID)
}
// CompleteTask marks a task as complete.
func (e *SDLCExecutor) CompleteTask(ctx context.Context, podName, slug, taskID string) error {
return e.execSDLCNoOutput(ctx, podName, "task", "complete", slug, taskID)
}
// BlockTask marks a task as blocked.
func (e *SDLCExecutor) BlockTask(ctx context.Context, podName, slug, taskID string) error {
return e.execSDLCNoOutput(ctx, podName, "task", "block", slug, taskID)
}
// QueryBlocked returns all blocked features.
func (e *SDLCExecutor) QueryBlocked(ctx context.Context, podName string) ([]port.BlockedInfo, error) {
out, err := e.execSDLC(ctx, podName, "query", "blocked")
if err != nil {
return nil, err
}
var blocked []port.BlockedInfo
if err := json.Unmarshal(out, &blocked); err != nil {
return nil, fmt.Errorf("parse sdlc blocked query: %w", err)
}
return blocked, nil
}
// QueryReady returns features ready for work.
func (e *SDLCExecutor) QueryReady(ctx context.Context, podName string) ([]port.ReadyInfo, error) {
out, err := e.execSDLC(ctx, podName, "query", "ready")
if err != nil {
return nil, err
}
var ready []port.ReadyInfo
if err := json.Unmarshal(out, &ready); err != nil {
return nil, fmt.Errorf("parse sdlc ready query: %w", err)
}
return ready, nil
}
// QueryNeedsApproval returns features awaiting approval.
func (e *SDLCExecutor) QueryNeedsApproval(ctx context.Context, podName string) ([]port.ApprovalInfo, error) {
out, err := e.execSDLC(ctx, podName, "query", "needs-approval")
if err != nil {
return nil, err
}
var pending []port.ApprovalInfo
if err := json.Unmarshal(out, &pending); err != nil {
return nil, fmt.Errorf("parse sdlc approval query: %w", err)
}
return pending, nil
}
// Compile-time interface check.
var _ port.SDLCExecutor = (*SDLCExecutor)(nil)

View File

@ -0,0 +1,114 @@
package kubernetes
import (
"errors"
"testing"
"github.com/orchard9/rdev/internal/sdlc"
)
func TestMapExecError(t *testing.T) {
exec := &SDLCExecutor{namespace: "test"}
baseErr := errors.New("exit status 1")
tests := []struct {
name string
stderr string
want error
wantMsg string
}{
{
name: "not initialized",
stderr: "Error: sdlc not initialized: run 'sdlc init'",
want: sdlc.ErrNotInitialized,
},
{
name: "feature not found",
stderr: "Error: feature not found",
want: sdlc.ErrFeatureNotFound,
},
{
name: "feature already exists",
stderr: "Error: feature already exists",
want: sdlc.ErrFeatureExists,
},
{
name: "invalid phase transition",
stderr: "Error: invalid phase transition: cannot move from draft to implementation (backward)",
want: sdlc.ErrInvalidTransition,
},
{
name: "invalid phase",
stderr: "Error: invalid phase: xyz",
want: sdlc.ErrInvalidPhase,
},
{
name: "task not found",
stderr: "Error: task not found",
want: sdlc.ErrTaskNotFound,
},
{
name: "artifact not found",
stderr: "Error: artifact not found",
want: sdlc.ErrArtifactNotFound,
},
{
name: "invalid slug",
stderr: "Error: invalid slug: must be lowercase alphanumeric with hyphens",
want: sdlc.ErrInvalidSlug,
},
{
name: "invalid artifact type",
stderr: "Error: invalid artifact type: foobar",
want: sdlc.ErrInvalidArtifact,
},
{
name: "unknown error with stderr",
stderr: "something unexpected happened",
wantMsg: "sdlc exec: something unexpected happened: exit status 1",
},
{
name: "unknown error without stderr",
stderr: "",
wantMsg: "sdlc exec: exit status 1",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := exec.mapExecError(tt.stderr, baseErr)
if tt.want != nil {
if !errors.Is(got, tt.want) {
t.Errorf("mapExecError() = %v, want %v", got, tt.want)
}
} else if tt.wantMsg != "" {
if got.Error() != tt.wantMsg {
t.Errorf("mapExecError() message = %q, want %q", got.Error(), tt.wantMsg)
}
}
})
}
}
func TestMapExecError_WhitespaceHandling(t *testing.T) {
exec := &SDLCExecutor{namespace: "test"}
baseErr := errors.New("exit status 1")
// Stderr with leading/trailing whitespace
got := exec.mapExecError(" feature not found\n ", baseErr)
if !errors.Is(got, sdlc.ErrFeatureNotFound) {
t.Errorf("expected ErrFeatureNotFound, got %v", got)
}
}
func TestNewSDLCExecutor(t *testing.T) {
exec := NewSDLCExecutor(SDLCExecutorConfig{
Namespace: "rdev",
})
if exec.namespace != "rdev" {
t.Errorf("namespace = %q, want %q", exec.namespace, "rdev")
}
if exec.logger == nil {
t.Error("logger should not be nil")
}
}

View File

@ -13,5 +13,9 @@ APP_DEBUG=true
LOG_LEVEL=debug
LOG_FORMAT=text
# Auth (set AUTH_ENABLED=true to require JWT for protected routes)
AUTH_ENABLED=false
JWT_SECRET=dev-secret-change-in-production
# Database (if needed)
DATABASE_URL=postgres://dev:dev@localhost:5432/{{PROJECT_NAME}}?sslmode=disable

View File

@ -3,6 +3,9 @@ package handlers
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"{{GO_MODULE}}/pkg/app"
"{{GO_MODULE}}/pkg/httperror"
"{{GO_MODULE}}/pkg/httpresponse"
@ -25,34 +28,82 @@ type CreateRequest struct {
Description string `json:"description" validate:"max=500"`
}
// CreateResponse is the response for creating an example.
type CreateResponse struct {
// UpdateRequest is the request body for updating an example.
type UpdateRequest struct {
Name string `json:"name" validate:"omitempty,min=1,max=100"`
Description string `json:"description" validate:"max=500"`
}
// ExampleResponse is the response for an example resource.
type ExampleResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// List returns a paginated list of examples.
// Demonstrates pagination query params and list responses.
func (h *Example) List(w http.ResponseWriter, r *http.Request) error {
// Example: Parse pagination query params
// page := r.URL.Query().Get("page")
// perPage := r.URL.Query().Get("per_page")
// Example: Fetch from database
// items, total, err := h.repo.List(r.Context(), page, perPage)
// if err != nil {
// return err
// }
// Placeholder response
items := []ExampleResponse{
{
ID: "550e8400-e29b-41d4-a716-446655440000",
Name: "Example Item 1",
Description: "First example item",
CreatedAt: "2024-01-15T10:30:00Z",
UpdatedAt: "2024-01-15T10:30:00Z",
},
{
ID: "550e8400-e29b-41d4-a716-446655440001",
Name: "Example Item 2",
Description: "Second example item",
CreatedAt: "2024-01-16T12:00:00Z",
UpdatedAt: "2024-01-16T12:00:00Z",
},
}
httpresponse.OK(w, r, items)
return nil
}
// Get returns an example by ID.
// Demonstrates returning HTTPErrors for common error cases.
func (h *Example) Get(w http.ResponseWriter, r *http.Request) error {
// Get ID from path parameter (using chi)
// id := chi.URLParam(r, "id")
id := chi.URLParam(r, "id")
// Example: resource not found
// if item == nil {
// return httperror.NotFoundf("example %s not found", id)
// Validate UUID format
if _, err := uuid.Parse(id); err != nil {
return httperror.BadRequest("invalid id format")
}
// Example: Fetch from database
// item, err := h.repo.Get(r.Context(), id)
// if err != nil {
// if errors.Is(err, ErrNotFound) {
// return httperror.NotFoundf("example %s not found", id)
// }
// return err
// }
// Example: forbidden access
// if !canAccess(user, item) {
// return httperror.Forbidden("access denied")
// }
// Success response
httpresponse.OK(w, r, map[string]any{
"id": "example-123",
"name": "Example Item",
"description": "This is an example item",
// Placeholder response
httpresponse.OK(w, r, ExampleResponse{
ID: id,
Name: "Example Item",
Description: "This is an example item",
CreatedAt: "2024-01-15T10:30:00Z",
UpdatedAt: "2024-01-15T10:30:00Z",
})
return nil
}
@ -63,27 +114,90 @@ func (h *Example) Create(w http.ResponseWriter, r *http.Request) error {
var req CreateRequest
// Bind and validate request body
// Returns HTTPError on failure, which Wrap will handle
if err := app.BindAndValidate(r, &req); err != nil {
return err
}
// Example: business logic error
// if exists(req.Name) {
// Example: Check for duplicates
// if exists, _ := h.repo.GetByName(r.Context(), req.Name); exists != nil {
// return httperror.Conflict("example with this name already exists")
// }
// Example: internal error (will be logged, generic message returned to client)
// if err := db.Create(item); err != nil {
// h.logger.Error("failed to create example", "error", err)
// return err // Generic errors become 500 Internal Error
// Example: Create in database
// item, err := h.repo.Create(r.Context(), req)
// if err != nil {
// return err
// }
// Success response
httpresponse.Created(w, r, CreateResponse{
ID: "example-456",
// Example: Access authenticated user
// user := auth.GetUser(r.Context())
// h.logger.Info("example created", "by", user.ID, "name", req.Name)
id := uuid.New().String()
httpresponse.Created(w, r, ExampleResponse{
ID: id,
Name: req.Name,
Description: req.Description,
CreatedAt: "2024-01-15T10:30:00Z",
UpdatedAt: "2024-01-15T10:30:00Z",
})
return nil
}
// Update updates an existing example.
// Demonstrates partial updates with BindAndValidate.
func (h *Example) Update(w http.ResponseWriter, r *http.Request) error {
id := chi.URLParam(r, "id")
if _, err := uuid.Parse(id); err != nil {
return httperror.BadRequest("invalid id format")
}
var req UpdateRequest
if err := app.BindAndValidate(r, &req); err != nil {
return err
}
// Example: Fetch existing, apply updates, save
// item, err := h.repo.Get(r.Context(), id)
// if err != nil {
// if errors.Is(err, ErrNotFound) {
// return httperror.NotFoundf("example %s not found", id)
// }
// return err
// }
// if err := h.repo.Update(r.Context(), id, req); err != nil {
// return err
// }
httpresponse.OK(w, r, ExampleResponse{
ID: id,
Name: req.Name,
Description: req.Description,
CreatedAt: "2024-01-15T10:30:00Z",
UpdatedAt: "2024-01-16T14:00:00Z",
})
return nil
}
// Delete deletes an example by ID.
// Demonstrates no-content response.
func (h *Example) Delete(w http.ResponseWriter, r *http.Request) error {
id := chi.URLParam(r, "id")
if _, err := uuid.Parse(id); err != nil {
return httperror.BadRequest("invalid id format")
}
// Example: Delete from database
// if err := h.repo.Delete(r.Context(), id); err != nil {
// if errors.Is(err, ErrNotFound) {
// return httperror.NotFoundf("example %s not found", id)
// }
// return err
// }
httpresponse.NoContent(w)
return nil
}

View File

@ -0,0 +1,183 @@
package handlers
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"{{GO_MODULE}}/pkg/logging"
)
func newTestLogger() *logging.Logger {
return logging.New(logging.Config{
Level: logging.LevelDebug,
Format: logging.FormatText,
})
}
func TestExample_List(t *testing.T) {
handler := NewExample(newTestLogger())
r := chi.NewRouter()
r.Get("/api/v1/examples", func(w http.ResponseWriter, r *http.Request) {
if err := handler.List(w, r); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
req := httptest.NewRequest(http.MethodGet, "/api/v1/examples", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
var resp map[string]any
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
data, ok := resp["data"]
if !ok {
t.Fatal("expected 'data' field in response")
}
items, ok := data.([]any)
if !ok {
t.Fatal("expected 'data' to be an array")
}
if len(items) == 0 {
t.Error("expected at least one item in response")
}
}
func TestExample_Get(t *testing.T) {
handler := NewExample(newTestLogger())
tests := []struct {
name string
id string
wantStatus int
}{
{
name: "valid uuid",
id: "550e8400-e29b-41d4-a716-446655440000",
wantStatus: http.StatusOK,
},
{
name: "invalid uuid",
id: "not-a-uuid",
wantStatus: http.StatusBadRequest,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := chi.NewRouter()
r.Get("/api/v1/examples/{id}", func(w http.ResponseWriter, r *http.Request) {
if err := handler.Get(w, r); err != nil {
// Error-returning handler: convert error to status
w.WriteHeader(http.StatusBadRequest)
return
}
})
req := httptest.NewRequest(http.MethodGet, "/api/v1/examples/"+tt.id, nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != tt.wantStatus {
t.Errorf("expected status %d, got %d", tt.wantStatus, w.Code)
}
})
}
}
func TestExample_Create(t *testing.T) {
handler := NewExample(newTestLogger())
tests := []struct {
name string
body any
wantStatus int
}{
{
name: "valid request",
body: CreateRequest{
Name: "Test Example",
Description: "A test description",
},
wantStatus: http.StatusCreated,
},
{
name: "empty body",
body: nil,
wantStatus: http.StatusBadRequest,
},
{
name: "missing required name",
body: map[string]string{
"description": "no name provided",
},
wantStatus: http.StatusUnprocessableEntity,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := chi.NewRouter()
r.Post("/api/v1/examples", func(w http.ResponseWriter, r *http.Request) {
if err := handler.Create(w, r); err != nil {
// Simulate Wrap behavior for tests
w.WriteHeader(http.StatusBadRequest)
return
}
})
var body []byte
if tt.body != nil {
var err error
body, err = json.Marshal(tt.body)
if err != nil {
t.Fatalf("failed to marshal body: %v", err)
}
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/examples", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
// For the valid case, check 201
if tt.name == "valid request" && w.Code != http.StatusCreated {
t.Errorf("expected status %d, got %d", http.StatusCreated, w.Code)
}
})
}
}
func TestExample_Delete(t *testing.T) {
handler := NewExample(newTestLogger())
r := chi.NewRouter()
r.Delete("/api/v1/examples/{id}", func(w http.ResponseWriter, r *http.Request) {
if err := handler.Delete(w, r); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
})
req := httptest.NewRequest(http.MethodDelete, "/api/v1/examples/550e8400-e29b-41d4-a716-446655440000", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusNoContent {
t.Errorf("expected status 204, got %d", w.Code)
}
}

View File

@ -3,23 +3,46 @@ package api
import (
"{{GO_MODULE}}/pkg/app"
"{{GO_MODULE}}/pkg/auth"
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/api/handlers"
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/config"
)
// RegisterRoutes registers all HTTP routes for the service.
func RegisterRoutes(application *app.App) {
logger := application.Logger()
cfg := config.Load()
// Initialize handlers
healthHandler := handlers.NewHealth(logger)
exampleHandler := handlers.NewExample(logger)
// Build and mount OpenAPI spec
spec := NewServiceSpec()
application.EnableDocs(spec)
// Register API routes
application.Route("/api/v1", func(r app.Router) {
r.Get("/health", healthHandler.Check)
// Example routes using Wrap pattern for error-returning handlers
r.Get("/example", app.Wrap(exampleHandler.Get))
r.Post("/example", app.Wrap(exampleHandler.Create))
// Public routes (no auth required)
r.Get("/examples", app.Wrap(exampleHandler.List))
r.Get("/examples/{id}", app.Wrap(exampleHandler.Get))
// Protected routes (auth required when enabled)
r.Group(func(r app.Router) {
if cfg.AuthEnabled {
r.Use(auth.Middleware(auth.MiddlewareConfig{
Validator: auth.NewJWTValidator(auth.JWTConfig{
Secret: []byte(cfg.JWTSecret),
Issuer: "{{PROJECT_NAME}}",
}),
}))
}
r.Post("/examples", app.Wrap(exampleHandler.Create))
r.Put("/examples/{id}", app.Wrap(exampleHandler.Update))
r.Delete("/examples/{id}", app.Wrap(exampleHandler.Delete))
})
})
}

View File

@ -0,0 +1,112 @@
package api
import "{{GO_MODULE}}/pkg/openapi"
// NewServiceSpec builds the OpenAPI specification for the {{COMPONENT_NAME}} service.
func NewServiceSpec() *openapi.OpenAPISpec {
spec := openapi.NewOpenAPISpec("{{COMPONENT_NAME}} API", "1.0.0").
WithDescription("REST API for the {{COMPONENT_NAME}} service").
WithBearerSecurity("bearer", "JWT authentication token").
WithTag("Health", "Service health endpoints").
WithTag("Examples", "Example CRUD endpoints")
// Define reusable schemas
spec.WithSchema("Example", openapi.Object(map[string]openapi.Schema{
"id": openapi.UUID().WithDescription("Unique identifier"),
"name": openapi.String().WithDescription("Name of the example").WithExample("My Example"),
"description": openapi.String().WithDescription("Optional description").WithExample("A description"),
"created_at": openapi.DateTime().WithDescription("Creation timestamp"),
"updated_at": openapi.DateTime().WithDescription("Last update timestamp"),
}, "id", "name"))
spec.WithSchema("CreateExampleRequest", openapi.Object(map[string]openapi.Schema{
"name": openapi.StringWithMinMax(1, 100).WithDescription("Name of the example"),
"description": openapi.StringWithMinMax(0, 500).WithDescription("Optional description"),
}, "name"))
spec.WithSchema("UpdateExampleRequest", openapi.Object(map[string]openapi.Schema{
"name": openapi.StringWithMinMax(1, 100).WithDescription("Updated name"),
"description": openapi.StringWithMinMax(0, 500).WithDescription("Updated description"),
}))
// Health
spec.AddPath("/api/v1/health", "get", map[string]any{
"summary": "Health check",
"tags": []string{"Health"},
"responses": map[string]any{
"200": openapi.OpResponse("Service is healthy", openapi.Object(map[string]openapi.Schema{
"service": openapi.String(),
"status": openapi.String(),
})),
},
})
// List examples
spec.AddPath("/api/v1/examples", "get", map[string]any{
"summary": "List examples",
"description": "Returns a paginated list of examples.",
"tags": []string{"Examples"},
"parameters": []any{openapi.PageParam(), openapi.PerPageParam()},
"responses": map[string]any{
"200": openapi.OpResponse("Success", openapi.ResponseSchema(openapi.RefArray("Example"))),
},
})
// Get example
spec.AddPath("/api/v1/examples/{id}", "get", map[string]any{
"summary": "Get example by ID",
"tags": []string{"Examples"},
"parameters": []any{openapi.IDParam()},
"responses": map[string]any{
"200": openapi.OpResponse("Success", openapi.ResponseSchema(openapi.Ref("Example"))),
"404": openapi.OpResponse("Not found", openapi.ErrorResponseSchema()),
},
})
// Create example
spec.AddPath("/api/v1/examples", "post", map[string]any{
"summary": "Create example",
"description": "Creates a new example. Requires authentication.",
"tags": []string{"Examples"},
"security": []map[string][]string{{"bearer": {}}},
"requestBody": openapi.RequestBody(openapi.Ref("CreateExampleRequest"), true),
"responses": map[string]any{
"201": openapi.OpResponse("Created", openapi.ResponseSchema(openapi.Ref("Example"))),
"400": openapi.OpResponse("Bad request", openapi.ErrorResponseSchema()),
"401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()),
"422": openapi.OpResponse("Validation error", openapi.ErrorResponseSchema()),
},
})
// Update example
spec.AddPath("/api/v1/examples/{id}", "put", map[string]any{
"summary": "Update example",
"description": "Updates an existing example. Requires authentication.",
"tags": []string{"Examples"},
"security": []map[string][]string{{"bearer": {}}},
"parameters": []any{openapi.IDParam()},
"requestBody": openapi.RequestBody(openapi.Ref("UpdateExampleRequest"), true),
"responses": map[string]any{
"200": openapi.OpResponse("Updated", openapi.ResponseSchema(openapi.Ref("Example"))),
"400": openapi.OpResponse("Bad request", openapi.ErrorResponseSchema()),
"401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()),
"404": openapi.OpResponse("Not found", openapi.ErrorResponseSchema()),
},
})
// Delete example
spec.AddPath("/api/v1/examples/{id}", "delete", map[string]any{
"summary": "Delete example",
"description": "Deletes an example by ID. Requires authentication.",
"tags": []string{"Examples"},
"security": []map[string][]string{{"bearer": {}}},
"parameters": []any{openapi.IDParam()},
"responses": map[string]any{
"204": openapi.OpResponseNoContent(),
"401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()),
"404": openapi.OpResponse("Not found", openapi.ErrorResponseSchema()),
},
})
return spec
}

View File

@ -2,6 +2,9 @@
package config
import (
"os"
"strings"
"{{GO_MODULE}}/pkg/config"
)
@ -11,22 +14,21 @@ type Config struct {
Server config.ServerConfig
Database config.DatabaseConfig
Logging config.LoggingConfig
// Add service-specific config fields here
// Auth
AuthEnabled bool
JWTSecret string
}
// Load reads configuration from environment variables.
func Load() (*Config, error) {
if err := config.Init(config.Options{
AppName: "{{COMPONENT_NAME}}",
DefaultPort: {{PORT}},
}); err != nil {
return nil, err
}
func Load() *Config {
return &Config{
AppConfig: config.ReadAppConfig(),
Server: config.ReadServerConfig(),
Database: config.ReadDatabaseConfig(),
Logging: config.ReadLoggingConfig(),
}, nil
AuthEnabled: strings.EqualFold(os.Getenv("AUTH_ENABLED"), "true"),
JWTSecret: os.Getenv("JWT_SECRET"),
}
}

View File

@ -0,0 +1,151 @@
# Backend API Patterns
## Handler Pattern (Wrap)
All handlers return `error` and are wrapped with `app.Wrap()`:
```go
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) error {
id := chi.URLParam(r, "id")
item, err := h.svc.Get(r.Context(), id)
if err != nil {
if errors.Is(err, ErrNotFound) {
return httperror.NotFoundf("item %s not found", id)
}
return err // becomes 500
}
httpresponse.OK(w, r, item)
return nil
}
// In routes.go:
r.Get("/items/{id}", app.Wrap(handler.Get))
```
## Request Binding
Use `app.Bind` or `app.BindAndValidate`:
```go
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) error {
var req CreateRequest
if err := app.BindAndValidate(r, &req); err != nil {
return err // returns 400 or 422 HTTPError
}
// req is decoded and validated
}
```
Validation uses go-playground/validator struct tags:
- `validate:"required"` - field is required
- `validate:"min=1,max=100"` - length constraints
- `validate:"email"` - email format
- `validate:"uuid"` - UUID format
## HTTPError Sentinels
Use `httperror` factories to return typed errors:
| Function | Status | When to use |
|----------|--------|-------------|
| `httperror.BadRequest(msg)` | 400 | Invalid input format |
| `httperror.Unauthorized(msg)` | 401 | Missing/invalid credentials |
| `httperror.Forbidden(msg)` | 403 | No permission |
| `httperror.NotFoundf(fmt, args)` | 404 | Resource doesn't exist |
| `httperror.Conflict(msg)` | 409 | Duplicate resource |
| `httperror.Validation(msg)` | 422 | Struct validation failure |
| `httperror.Internal(msg)` | 500 | Server error (prefer returning raw err) |
Add details with `httperror.WithDetails(err, details)`.
## Response Envelope
All responses use the standard envelope from `httpresponse`:
```json
{
"data": { ... },
"meta": {
"request_id": "abc-123",
"timestamp": "2024-01-15T10:30:00Z"
}
}
```
Use `httpresponse.OK(w, r, data)`, `httpresponse.Created(w, r, data)`, `httpresponse.NoContent(w)`.
## OpenAPI Documentation
Annotate endpoints in a `spec.go` file:
```go
spec := openapi.NewOpenAPISpec("Service Name", "1.0.0").
WithBearerSecurity("bearer", "JWT token")
spec.WithSchema("Item", openapi.Object(map[string]openapi.Schema{
"id": openapi.UUID(),
"name": openapi.String().WithExample("My Item"),
}, "id", "name"))
spec.AddPath("/api/v1/items", "get", map[string]any{
"summary": "List items",
"tags": []string{"Items"},
"responses": map[string]any{
"200": openapi.OpResponse("Success", openapi.RefArray("Item")),
},
})
```
Mount with `application.EnableDocs(spec)` to get `/docs` (Scalar UI) and `/openapi.json`.
## Auth Integration
Auth is opt-in via `AUTH_ENABLED=true`:
```go
// In routes.go - protected route group
r.Group(func(r app.Router) {
if cfg.AuthEnabled {
r.Use(auth.Middleware(auth.MiddlewareConfig{
Validator: auth.NewJWTValidator(auth.JWTConfig{
Secret: []byte(cfg.JWTSecret),
}),
}))
}
r.Post("/items", app.Wrap(handler.Create))
})
```
Access user in handlers:
```go
user := auth.GetUser(r.Context())
if user != nil {
logger.Info("created by", "user", user.ID)
}
```
## Health Checks
Basic health is auto-registered at `/health` and `/ready`.
For dependency checks:
```go
healthHandler := app.NewHealthHandler(app.HealthConfig{
Service: "my-service",
Checks: map[string]app.HealthChecker{
"database": app.PingChecker(db.PingContext),
"redis": app.PingChecker(redis.Ping),
},
})
r.Get("/health", healthHandler)
```
## Chassis Package
The `pkg/chassis` package re-exports `pkg/app` types for convenience:
```go
import "{{GO_MODULE}}/pkg/chassis"
svc := chassis.New("my-service", chassis.WithDefaultPort(8080))
```

View File

@ -0,0 +1,125 @@
# Frontend Design System
## UI Components (`@{{PROJECT_NAME}}/ui`)
Available components from `packages/ui`:
| Component | Import | Variants |
|-----------|--------|----------|
| Button | `Button` | default, destructive, outline, secondary, ghost, link |
| Card | `Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter` | - |
| Input | `Input` | - |
| Label | `Label` | - |
| Badge | `Badge` | default, secondary, outline, success, warning, error, info |
| Dialog | `Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose` | - |
| Table | `Table, TableHeader, TableBody, TableRow, TableHead, TableCell` | - |
| Select | `Select, SelectTrigger, SelectContent, SelectItem, SelectValue` | - |
| Checkbox | `Checkbox` | - |
| Alert | `Alert, AlertTitle, AlertDescription` | default, destructive, success, warning |
| Textarea | `Textarea` | - |
| DropdownMenu | `DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, ...` | - |
| Sheet | `Sheet, SheetTrigger, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter` | side: top, right, bottom, left |
Usage:
```tsx
import { Button, Badge, Card, CardContent } from '@{{PROJECT_NAME}}/ui';
<Card>
<CardContent>
<Badge variant="success">Active</Badge>
<Button variant="outline">Edit</Button>
</CardContent>
</Card>
```
## CSS Tokens
All components use CSS custom properties for theming. Define these in your app's globals.css:
### Colors
- `--background` - Page background
- `--surface-100` - Card/input backgrounds
- `--surface-200` - Hover states, secondary surfaces
- `--text-primary` - Main text
- `--text-secondary` - Secondary text
- `--text-muted` - Placeholder, hint text
- `--accent` - Primary accent color
- `--accent-foreground` - Text on accent
- `--border` - Border color
### Semantic Colors
- `--success`, `--success-bg`, `--success-border` - Success states
- `--warning`, `--warning-bg`, `--warning-border` - Warning states
- `--error`, `--error-bg`, `--error-border` - Error states
- `--info`, `--info-bg`, `--info-border` - Info states
### Z-Index
- `--z-popover` - Dropdowns, tooltips
- `--z-modal` - Dialogs, sheets
Import base tokens: `import '@{{PROJECT_NAME}}/ui/styles';`
## Layout (`@{{PROJECT_NAME}}/layout`)
DashboardShell provides the standard app layout:
```tsx
import { DashboardShell, Sidebar, Header } from '@{{PROJECT_NAME}}/layout';
<DashboardShell
sidebar={<Sidebar items={navItems} />}
header={<Header title="Dashboard" />}
>
{children}
</DashboardShell>
```
## Auth (`@{{PROJECT_NAME}}/auth`)
AuthProvider and ProtectedRoute for client-side auth:
```tsx
import { AuthProvider, ProtectedRoute, useAuth } from '@{{PROJECT_NAME}}/auth';
// Wrap app in AuthProvider
<AuthProvider>
<App />
</AuthProvider>
// Protect routes
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
// Access auth state
const { user, isAuthenticated, login, logout } = useAuth();
```
## API Client (`@{{PROJECT_NAME}}/api-client`)
Typed API client with auth:
```tsx
import { createAPIClient } from '@{{PROJECT_NAME}}/api-client';
const api = createAPIClient({
baseURL: process.env.NEXT_PUBLIC_API_URL,
});
// Use in server actions or client components
const items = await api.examples.list();
const item = await api.examples.get(id);
await api.examples.create({ name: 'New Item' });
```
## Icons
Re-exported from lucide-react via `@{{PROJECT_NAME}}/ui`:
```tsx
import { Plus, Search, Settings, Trash2, User } from '@{{PROJECT_NAME}}/ui';
```
## Dark Theme
All components default to dark theme using CSS variables. The design system is dark-first with surface layering (surface-100 lighter than background, surface-200 lighter than surface-100).

View File

@ -8,6 +8,8 @@
|-------------------|-----------|
| **Set up local dev** | [local/setup.md](.claude/guides/local/setup.md) |
| **Build a feature** | [feature-development.md](.claude/guides/feature-development.md) |
| **Backend API patterns** | [backend/api-patterns.md](.claude/guides/backend/api-patterns.md) |
| **Frontend design system** | [frontend/design-system.md](.claude/guides/frontend/design-system.md) |
| **Deploy** | [ops/deploying.md](.claude/guides/ops/deploying.md) |
## Quick Reference
@ -23,6 +25,17 @@
./scripts/discover.sh
```
## Critical Rules
- **Handler pattern:** All handlers return `error`, wrapped with `app.Wrap()`. HTTPErrors map to status codes; raw errors become 500.
- **Request binding:** Always use `app.Bind()` or `app.BindAndValidate()`. Never use raw `json.NewDecoder`.
- **Error types:** Use `httperror.BadRequest`, `httperror.NotFound`, etc. Never bare `http.Error()`.
- **Response envelope:** Use `httpresponse.OK`, `httpresponse.Created`, `httpresponse.NoContent`. All responses use `{data, meta}` envelope.
- **Auth middleware:** Auth is opt-in. Use `auth.Middleware()` in route groups for protected endpoints.
- **OpenAPI first:** Document endpoints in `spec.go` using `openapi.*` helpers. Mount with `application.EnableDocs(spec)`.
- **CSS variables:** All UI components use CSS custom properties (`var(--background)`, `var(--accent)`, etc.). Never hardcode colors.
- **Monorepo imports:** Go packages from `{{GO_MODULE}}/pkg/*`, TypeScript from `@{{PROJECT_NAME}}/*`.
## Architecture
```
@ -31,8 +44,24 @@
├── workers/ # Background workers (no port)
├── apps/ # Frontend applications (port 3001+)
├── cli/ # CLI tools (no port)
├── packages/ # Shared TypeScript packages (@{{PROJECT_NAME}}/*)
├── pkg/ # Shared Go packages ({{GO_MODULE}}/pkg/*)
├── packages/ # Shared TypeScript packages
│ ├── ui/ # UI components (@{{PROJECT_NAME}}/ui)
│ ├── layout/ # Dashboard layout (@{{PROJECT_NAME}}/layout)
│ ├── auth/ # Auth provider (@{{PROJECT_NAME}}/auth)
│ ├── api-client/ # Typed API client (@{{PROJECT_NAME}}/api-client)
│ └── logger/ # HTTP/console logger (@{{PROJECT_NAME}}/logger)
├── pkg/ # Shared Go packages
│ ├── app/ # Service bootstrapper (Wrap, Bind, Health)
│ ├── chassis/ # Facade re-exporting app types
│ ├── openapi/ # OpenAPI 3.0 spec builder + Scalar docs
│ ├── httperror/ # Typed HTTP errors
│ ├── httpresponse/ # Response envelope helpers
│ ├── httpvalidation/ # Struct validation
│ ├── middleware/ # RequestID, CORS, Recovery, Logger
│ ├── auth/ # JWT, API key, middleware
│ ├── config/ # Viper-based configuration
│ ├── httpclient/ # Resilient HTTP client
│ └── logging/ # slog wrapper
└── scripts/ # Development & CI scripts
```
@ -40,7 +69,7 @@
|------|----------|------------|---------|
| services/ | Go | 8001+ | REST APIs, backend services |
| workers/ | Go | none | Background jobs, queue consumers |
| apps/ | TypeScript | 3001+ | React, Astro frontends |
| apps/ | TypeScript | 3001+ | React, Next.js, Astro frontends |
| cli/ | Go | none | CLI tools, scripts |
| packages/ | TypeScript | none | Shared frontend packages |
| pkg/ | Go | none | Shared backend packages |

View File

@ -20,6 +20,7 @@
"dependencies": {
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slot": "^1.0.2",

View File

@ -0,0 +1,62 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '../utils/cn';
const alertVariants = cva(
'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-[var(--text-secondary)]',
{
variants: {
variant: {
default: 'bg-[var(--background)] text-[var(--text-primary)] border-[var(--border)]',
destructive:
'border-[var(--error-border)] bg-[var(--error-bg)] text-[var(--error)] [&>svg]:text-[var(--error)]',
success:
'border-[var(--success-border)] bg-[var(--success-bg)] text-[var(--success)] [&>svg]:text-[var(--success)]',
warning:
'border-[var(--warning-border)] bg-[var(--warning-bg)] text-[var(--warning)] [&>svg]:text-[var(--warning)]',
},
},
defaultVariants: {
variant: 'default',
},
}
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
));
Alert.displayName = 'Alert';
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn('mb-1 font-medium leading-none tracking-tight', className)}
{...props}
/>
));
AlertTitle.displayName = 'AlertTitle';
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('text-sm [&_p]:leading-relaxed', className)}
{...props}
/>
));
AlertDescription.displayName = 'AlertDescription';
export { Alert, AlertTitle, AlertDescription, alertVariants };

View File

@ -0,0 +1,189 @@
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check, ChevronRight, Circle } from 'lucide-react';
import { cn } from '../utils/cn';
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-[var(--surface-200)] data-[state=open]:bg-[var(--surface-200)]',
inset && 'pl-8',
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-[var(--z-popover)] min-w-[8rem] overflow-hidden rounded-md border border-[var(--border)] bg-[var(--surface-100)] p-1 text-[var(--text-primary)] shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-[var(--z-popover)] min-w-[8rem] overflow-hidden rounded-md border border-[var(--border)] bg-[var(--surface-100)] p-1 text-[var(--text-primary)] shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-[var(--surface-200)] focus:text-[var(--text-primary)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-[var(--surface-200)] focus:text-[var(--text-primary)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-[var(--surface-200)] focus:text-[var(--text-primary)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
'px-2 py-1.5 text-sm font-semibold',
inset && 'pl-8',
className
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-[var(--border)]', className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn('ml-auto text-xs tracking-widest text-[var(--text-muted)]', className)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

View File

@ -0,0 +1,134 @@
import * as React from 'react';
import * as SheetPrimitive from '@radix-ui/react-dialog';
import { cva, type VariantProps } from 'class-variance-authority';
import { X } from 'lucide-react';
import { cn } from '../utils/cn';
const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger;
const SheetClose = SheetPrimitive.Close;
const SheetPortal = SheetPrimitive.Portal;
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
'fixed inset-0 z-[var(--z-modal)] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
ref={ref}
/>
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
'fixed z-[var(--z-modal)] gap-4 bg-[var(--background)] p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
{
variants: {
side: {
top: 'inset-x-0 top-0 border-b border-[var(--border)] data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
bottom:
'inset-x-0 bottom-0 border-t border-[var(--border)] data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
left: 'inset-y-0 left-0 h-full w-3/4 border-r border-[var(--border)] data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
right:
'inset-y-0 right-0 h-full w-3/4 border-l border-[var(--border)] data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
},
},
defaultVariants: {
side: 'right',
},
}
);
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = 'right', className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-[var(--background)] transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-[var(--accent)] focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-[var(--surface-100)]">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
));
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-2 text-center sm:text-left',
className
)}
{...props}
/>
);
SheetHeader.displayName = 'SheetHeader';
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className
)}
{...props}
/>
);
SheetFooter.displayName = 'SheetFooter';
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold text-[var(--text-primary)]', className)}
{...props}
/>
));
SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn('text-sm text-[var(--text-muted)]', className)}
{...props}
/>
));
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};

View File

@ -0,0 +1,23 @@
import * as React from 'react';
import { cn } from '../utils/cn';
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'flex min-h-[80px] w-full rounded-md border border-[var(--border)] bg-[var(--surface-100)] px-3 py-2 text-sm text-[var(--text-primary)] ring-offset-[var(--background)] placeholder:text-[var(--text-muted)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
);
}
);
Textarea.displayName = 'Textarea';
export { Textarea };

View File

@ -11,6 +11,10 @@ export { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, Dialog
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from './components/Table';
export { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from './components/Select';
export { Checkbox, type CheckboxProps } from './components/Checkbox';
export { Alert, AlertTitle, AlertDescription } from './components/Alert';
export { Textarea, type TextareaProps } from './components/Textarea';
export { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuGroup, DropdownMenuPortal, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuRadioGroup } from './components/DropdownMenu';
export { Sheet, SheetPortal, SheetOverlay, SheetTrigger, SheetClose, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription } from './components/Sheet';
// Icons (re-export commonly used ones)
export {

View File

@ -27,6 +27,7 @@ import (
"{{GO_MODULE}}/pkg/httpresponse"
"{{GO_MODULE}}/pkg/logging"
"{{GO_MODULE}}/pkg/middleware"
"{{GO_MODULE}}/pkg/openapi"
)
// Router is an alias for chi.Router, exposing it for handler mounting.
@ -291,6 +292,19 @@ func (a *App) ListenAddr() string {
return a.serverConfig.Addr()
}
// EnableDocs adds /docs and /openapi.json endpoints to the application.
// It mounts the Scalar UI at /docs and the OpenAPI JSON spec at /openapi.json.
//
// Example:
//
// spec := openapi.NewOpenAPISpec("My Service", "1.0.0")
// // ... add paths and schemas ...
// application.EnableDocs(spec)
func (a *App) EnableDocs(spec *openapi.OpenAPISpec) {
openapi.Mount(a.router, spec)
a.logger.Info("API documentation enabled", "docs", "/docs", "spec", "/openapi.json")
}
// ServeHTTP implements http.Handler, allowing App to be used in tests.
func (a *App) ServeHTTP(w http.ResponseWriter, r *http.Request) {
a.router.ServeHTTP(w, r)

View File

@ -0,0 +1,37 @@
// Package chassis is the service framework for {{PROJECT_NAME}}.
// It re-exports key types from pkg/app, pkg/httperror, pkg/httpresponse,
// and pkg/httpvalidation for convenience.
//
// Example:
//
// func main() {
// svc := chassis.New("my-service", chassis.WithDefaultPort(8080))
// svc.GET("/users/{id}", app.Wrap(handlers.GetUser))
// svc.Run()
// }
package chassis
import "{{GO_MODULE}}/pkg/app"
// Re-export core types
type App = app.App
type Router = app.Router
type Option = app.Option
type HandlerFunc = app.HandlerFunc
type HealthChecker = app.HealthChecker
type HealthConfig = app.HealthConfig
// Re-export constructors
var (
New = app.New
WithDefaultPort = app.WithDefaultPort
WithLogger = app.WithLogger
Wrap = app.Wrap
WrapWithLogger = app.WrapWithLogger
Bind = app.Bind
BindAndValidate = app.BindAndValidate
BindStrict = app.BindStrict
NewHealthHandler = app.NewHealthHandler
PingChecker = app.PingChecker
HTTPChecker = app.HTTPChecker
)

View File

@ -3,9 +3,11 @@ module {{GO_MODULE}}/pkg
go 1.23
require (
github.com/bdpiprava/scalar-go v0.1.2
github.com/go-chi/chi/v5 v5.2.0
github.com/go-chi/cors v1.2.1
github.com/go-playground/validator/v10 v10.23.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
github.com/spf13/viper v1.19.0
)

View File

@ -0,0 +1,49 @@
package openapi
import (
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
scalargo "github.com/bdpiprava/scalar-go"
)
// Mount registers /docs and /openapi.json endpoints on the router.
func Mount(r chi.Router, spec *OpenAPISpec) {
// Serve OpenAPI JSON
r.Get("/openapi.json", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
specBytes, err := spec.JSON()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_, _ = w.Write(specBytes)
})
// Serve Scalar docs UI
r.Get("/docs", func(w http.ResponseWriter, r *http.Request) {
scheme := "http"
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
scheme = proto
} else if r.TLS != nil {
scheme = "https"
}
specURL := fmt.Sprintf("%s://%s/openapi.json", scheme, r.Host)
html, err := scalargo.NewV2(
scalargo.WithSpecURL(specURL),
scalargo.WithDarkMode(),
)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = fmt.Fprint(w, html)
})
}

View File

@ -0,0 +1,188 @@
package openapi
import "strings"
// Parameter represents an OpenAPI parameter.
type Parameter map[string]any
// PathParam creates a required path parameter.
func PathParam(name, description string) Parameter {
return Parameter{
"name": name,
"in": "path",
"required": true,
"description": description,
"schema": String(),
}
}
// PathParamWithSchema creates a required path parameter with a custom schema.
func PathParamWithSchema(name, description string, schema Schema) Parameter {
return Parameter{
"name": name,
"in": "path",
"required": true,
"description": description,
"schema": schema,
}
}
// QueryParam creates a query parameter.
func QueryParam(name, description string, required bool) Parameter {
return Parameter{
"name": name,
"in": "query",
"required": required,
"description": description,
"schema": String(),
}
}
// QueryParamWithSchema creates a query parameter with a custom schema.
func QueryParamWithSchema(name, description string, required bool, schema Schema) Parameter {
return Parameter{
"name": name,
"in": "query",
"required": required,
"description": description,
"schema": schema,
}
}
// HeaderParam creates a header parameter.
func HeaderParam(name, description string, required bool) Parameter {
return Parameter{
"name": name,
"in": "header",
"required": required,
"description": description,
"schema": String(),
}
}
// CookieParam creates a cookie parameter.
func CookieParam(name, description string, required bool) Parameter {
return Parameter{
"name": name,
"in": "cookie",
"required": required,
"description": description,
"schema": String(),
}
}
// WithExample adds an example to a parameter.
func (p Parameter) WithExample(example any) Parameter {
p["example"] = example
return p
}
// WithDefault adds a default value to a parameter.
func (p Parameter) WithDefault(value any) Parameter {
if schema, ok := p["schema"].(Schema); ok {
schema["default"] = value
p["schema"] = schema
}
return p
}
// WithDeprecated marks a parameter as deprecated.
func (p Parameter) WithDeprecated(deprecated bool) Parameter {
p["deprecated"] = deprecated
return p
}
// IDParam creates a standard ID path parameter.
func IDParam() Parameter {
return PathParamWithSchema("id", "Resource identifier", UUID())
}
// PageParam creates a standard pagination page parameter.
func PageParam() Parameter {
return QueryParamWithSchema("page", "Page number (1-indexed)", false, Int().WithDefault(1))
}
// PerPageParam creates a standard items-per-page parameter.
func PerPageParam() Parameter {
return QueryParamWithSchema("per_page", "Items per page (max 100)", false, IntWithMinMax(1, 100).WithDefault(20))
}
// SortParam creates a sort parameter.
func SortParam(allowedFields ...string) Parameter {
desc := "Sort field and direction (e.g., name:asc, created_at:desc)"
if len(allowedFields) > 0 {
desc = "Sort by: " + strings.Join(allowedFields, ", ") + " (append :asc or :desc)"
}
return QueryParamWithSchema("sort", desc, false,
String().WithExample("created_at:desc"))
}
// SearchParam creates a search query parameter.
func SearchParam() Parameter {
return QueryParam("q", "Search query", false).WithExample("keyword")
}
// APIKeyHeader creates the X-API-Key header parameter.
func APIKeyHeader() Parameter {
return HeaderParam("X-API-Key", "API key for authentication", true)
}
// AuthorizationHeader creates the Authorization header parameter.
func AuthorizationHeader() Parameter {
return HeaderParam("Authorization", "Bearer token for authentication", true).
WithExample("Bearer eyJhbGciOiJIUzI1NiIs...")
}
// RequestBody creates a JSON request body.
func RequestBody(schema Schema, required bool) map[string]any {
return map[string]any{
"required": required,
"content": map[string]any{
"application/json": map[string]any{
"schema": schema,
},
},
}
}
// OpResponse creates a response definition for an OpenAPI operation.
func OpResponse(description string, schema Schema) map[string]any {
return map[string]any{
"description": description,
"content": map[string]any{
"application/json": map[string]any{
"schema": schema,
},
},
}
}
// OpResponseNoContent creates a 204 No Content response.
func OpResponseNoContent() map[string]any {
return map[string]any{
"description": "No content",
}
}
// OpResponses creates a responses map for an operation.
func OpResponses(responses map[string]map[string]any) map[string]any {
result := make(map[string]any, len(responses))
for code, resp := range responses {
result[code] = resp
}
return result
}
// OpStandardResponses returns common error responses to include in operations.
func OpStandardResponses() map[string]map[string]any {
return map[string]map[string]any{
"400": OpResponse("Bad request", ErrorResponseSchema()),
"401": OpResponse("Unauthorized", ErrorResponseSchema()),
"403": OpResponse("Forbidden", ErrorResponseSchema()),
"404": OpResponse("Not found", ErrorResponseSchema()),
"422": OpResponse("Unprocessable entity", ErrorResponseSchema()),
"429": OpResponse("Too many requests", ErrorResponseSchema()),
"500": OpResponse("Internal server error", ErrorResponseSchema()),
"503": OpResponse("Service unavailable", ErrorResponseSchema()),
}
}

View File

@ -0,0 +1,231 @@
package openapi
// Schema represents a JSON Schema for OpenAPI.
type Schema map[string]any
// String creates a string schema.
func String() Schema {
return Schema{"type": "string"}
}
// StringWithFormat creates a string schema with a format.
// Common formats: email, uri, uuid, date, date-time, password
func StringWithFormat(format string) Schema {
return Schema{"type": "string", "format": format}
}
// StringEnum creates a string schema restricted to specific values.
func StringEnum(values ...string) Schema {
return Schema{"type": "string", "enum": values}
}
// StringWithMinMax creates a string schema with length constraints.
func StringWithMinMax(min, max int) Schema {
s := Schema{"type": "string"}
if min > 0 {
s["minLength"] = min
}
if max > 0 {
s["maxLength"] = max
}
return s
}
// Int creates an integer schema.
func Int() Schema {
return Schema{"type": "integer"}
}
// IntWithMinMax creates an integer schema with constraints.
func IntWithMinMax(min, max int) Schema {
s := Schema{"type": "integer"}
if min != 0 {
s["minimum"] = min
}
if max != 0 {
s["maximum"] = max
}
return s
}
// Int64 creates a 64-bit integer schema.
func Int64() Schema {
return Schema{"type": "integer", "format": "int64"}
}
// Number creates a number (float) schema.
func Number() Schema {
return Schema{"type": "number"}
}
// Bool creates a boolean schema.
func Bool() Schema {
return Schema{"type": "boolean"}
}
// Array creates an array schema with the given item type.
func Array(items Schema) Schema {
return Schema{
"type": "array",
"items": items,
}
}
// Object creates an object schema with the given properties.
// Required fields can be specified separately.
func Object(props map[string]Schema, required ...string) Schema {
properties := make(map[string]any, len(props))
for k, v := range props {
properties[k] = v
}
s := Schema{
"type": "object",
"properties": properties,
}
if len(required) > 0 {
s["required"] = required
}
return s
}
// Ref creates a $ref to a schema in components/schemas.
func Ref(name string) Schema {
return Schema{"$ref": "#/components/schemas/" + name}
}
// RefArray creates an array of $ref items.
func RefArray(name string) Schema {
return Array(Ref(name))
}
// Nullable makes a schema nullable using oneOf pattern.
func Nullable(s Schema) Schema {
return Schema{
"oneOf": []Schema{
s,
{"type": "null"},
},
}
}
// WithDescription adds a description to a schema.
func (s Schema) WithDescription(desc string) Schema {
s["description"] = desc
return s
}
// WithExample adds an example to a schema.
func (s Schema) WithExample(example any) Schema {
s["example"] = example
return s
}
// WithDefault adds a default value to a schema.
func (s Schema) WithDefault(value any) Schema {
s["default"] = value
return s
}
// WithPattern adds a regex pattern to a string schema.
func (s Schema) WithPattern(pattern string) Schema {
s["pattern"] = pattern
return s
}
// Format sets the format for a schema.
func (s Schema) Format(format string) Schema {
s["format"] = format
return s
}
// Description is an alias for WithDescription for cleaner chaining.
func (s Schema) Description(desc string) Schema {
return s.WithDescription(desc)
}
// Example is an alias for WithExample for cleaner chaining.
func (s Schema) Example(example any) Schema {
return s.WithExample(example)
}
// Default is an alias for WithDefault for cleaner chaining.
func (s Schema) Default(value any) Schema {
return s.WithDefault(value)
}
// Pattern is an alias for WithPattern for cleaner chaining.
func (s Schema) Pattern(pattern string) Schema {
return s.WithPattern(pattern)
}
// UUID creates a UUID string schema.
func UUID() Schema {
return StringWithFormat("uuid").WithExample("550e8400-e29b-41d4-a716-446655440000")
}
// Email creates an email string schema.
func Email() Schema {
return StringWithFormat("email").WithExample("user@example.com")
}
// URL creates a URL string schema.
func URL() Schema {
return StringWithFormat("uri").WithExample("https://example.com")
}
// DateTime creates a date-time string schema.
func DateTime() Schema {
return StringWithFormat("date-time").WithExample("2024-01-15T10:30:00Z")
}
// Password creates a password string schema (hidden in docs).
func Password() Schema {
return StringWithFormat("password")
}
// Pagination creates a common pagination object schema.
func Pagination() Schema {
return Object(map[string]Schema{
"page": Int().WithDescription("Current page number").WithExample(1),
"per_page": Int().WithDescription("Items per page").WithExample(20),
"total": Int().WithDescription("Total number of items").WithExample(100),
"total_pages": Int().WithDescription("Total number of pages").WithExample(5),
})
}
// ResponseSchema creates the standard response envelope schema.
func ResponseSchema(dataSchema Schema) Schema {
return Object(map[string]Schema{
"data": dataSchema,
"meta": Object(map[string]Schema{
"request_id": String().WithDescription("Request correlation ID"),
"timestamp": DateTime().WithDescription("Response timestamp"),
}),
})
}
// ErrorResponseSchema creates the standard error response schema.
func ErrorResponseSchema() Schema {
return Object(map[string]Schema{
"error": Object(map[string]Schema{
"code": String().WithDescription("Machine-readable error code").WithExample("BAD_REQUEST"),
"message": String().WithDescription("Human-readable error message").WithExample("Invalid request"),
"details": Schema{"type": "object"}.WithDescription("Additional error details"),
}, "code", "message"),
"meta": Object(map[string]Schema{
"request_id": String().WithDescription("Request correlation ID"),
"timestamp": DateTime().WithDescription("Response timestamp"),
}),
})
}
// ValidationErrorSchema creates a validation error details schema.
func ValidationErrorSchema() Schema {
return Array(Object(map[string]Schema{
"field": String().WithDescription("Field that failed validation").WithExample("email"),
"message": String().WithDescription("Validation error message").WithExample("is required"),
}, "field", "message"))
}

View File

@ -0,0 +1,229 @@
// Package openapi provides an OpenAPI 3.0 specification builder and documentation endpoints.
//
// It includes:
// - OpenAPISpec: A builder for constructing OpenAPI 3.0 specifications
// - Schema helpers: Typed schema constructors (String, Int, Object, Array, etc.)
// - Parameter helpers: Path, query, header parameter builders
// - Documentation: Scalar UI and JSON spec serving via EnableDocs
//
// Example:
//
// spec := openapi.NewOpenAPISpec("My Service", "1.0.0").
// WithDescription("Service API documentation").
// WithBearerSecurity("bearer", "JWT authentication")
//
// spec.AddPath("/api/v1/items", "get", map[string]any{
// "summary": "List items",
// "tags": []string{"Items"},
// "responses": map[string]any{
// "200": openapi.OpResponse("Success", openapi.RefArray("Item")),
// },
// })
//
// application.EnableDocs(spec)
package openapi
import (
"encoding/json"
"sync"
)
// OpenAPIInfo contains metadata about the API.
type OpenAPIInfo struct {
Title string `json:"title"`
Description string `json:"description,omitempty"`
Version string `json:"version"`
}
// OpenAPIServer describes a server endpoint.
type OpenAPIServer struct {
URL string `json:"url"`
Description string `json:"description,omitempty"`
}
// OpenAPIComponents contains reusable schema definitions.
type OpenAPIComponents struct {
Schemas map[string]any `json:"schemas,omitempty"`
SecuritySchemes map[string]any `json:"securitySchemes,omitempty"`
}
// OpenAPISpec represents a minimal OpenAPI 3.0 specification.
type OpenAPISpec struct {
OpenAPI string `json:"openapi"`
Info OpenAPIInfo `json:"info"`
Servers []OpenAPIServer `json:"servers,omitempty"`
Paths map[string]map[string]any `json:"paths"`
Tags []OpenAPITag `json:"tags,omitempty"`
Components *OpenAPIComponents `json:"components,omitempty"`
Security []map[string][]string `json:"security,omitempty"`
mu sync.RWMutex
}
// OpenAPITag groups operations together.
type OpenAPITag struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
}
// NewOpenAPISpec creates a new OpenAPI specification builder.
func NewOpenAPISpec(title, version string) *OpenAPISpec {
return &OpenAPISpec{
OpenAPI: "3.0.3",
Info: OpenAPIInfo{
Title: title,
Version: version,
},
Paths: make(map[string]map[string]any),
}
}
// WithDescription sets the API description.
func (s *OpenAPISpec) WithDescription(desc string) *OpenAPISpec {
s.Info.Description = desc
return s
}
// WithServer adds a server to the spec.
func (s *OpenAPISpec) WithServer(url, description string) *OpenAPISpec {
s.Servers = append(s.Servers, OpenAPIServer{
URL: url,
Description: description,
})
return s
}
// WithTag adds a tag for grouping operations.
func (s *OpenAPISpec) WithTag(name, description string) *OpenAPISpec {
s.Tags = append(s.Tags, OpenAPITag{
Name: name,
Description: description,
})
return s
}
// AddPath adds an operation to the spec.
// method should be lowercase (get, post, put, patch, delete).
func (s *OpenAPISpec) AddPath(path, method string, operation map[string]any) *OpenAPISpec {
s.mu.Lock()
defer s.mu.Unlock()
if s.Paths[path] == nil {
s.Paths[path] = make(map[string]any)
}
s.Paths[path][method] = operation
return s
}
// ensureComponents initializes the Components field if nil.
// Must be called while holding s.mu.
func (s *OpenAPISpec) ensureComponents() {
if s.Components == nil {
s.Components = &OpenAPIComponents{
Schemas: make(map[string]any),
SecuritySchemes: make(map[string]any),
}
}
}
// WithSchema adds a reusable schema to components/schemas.
func (s *OpenAPISpec) WithSchema(name string, schema Schema) *OpenAPISpec {
s.mu.Lock()
defer s.mu.Unlock()
s.ensureComponents()
if s.Components.Schemas == nil {
s.Components.Schemas = make(map[string]any)
}
s.Components.Schemas[name] = schema
return s
}
// WithAPIKeySecurity adds API key security scheme.
func (s *OpenAPISpec) WithAPIKeySecurity(name, headerName, description string) *OpenAPISpec {
s.mu.Lock()
defer s.mu.Unlock()
s.ensureComponents()
if s.Components.SecuritySchemes == nil {
s.Components.SecuritySchemes = make(map[string]any)
}
s.Components.SecuritySchemes[name] = map[string]any{
"type": "apiKey",
"in": "header",
"name": headerName,
"description": description,
}
return s
}
// WithBearerSecurity adds Bearer token security scheme.
func (s *OpenAPISpec) WithBearerSecurity(name, description string) *OpenAPISpec {
s.mu.Lock()
defer s.mu.Unlock()
s.ensureComponents()
if s.Components.SecuritySchemes == nil {
s.Components.SecuritySchemes = make(map[string]any)
}
s.Components.SecuritySchemes[name] = map[string]any{
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT",
"description": description,
}
return s
}
// WithGlobalSecurity sets global security requirements.
func (s *OpenAPISpec) WithGlobalSecurity(schemeName string) *OpenAPISpec {
s.mu.Lock()
defer s.mu.Unlock()
s.Security = append(s.Security, map[string][]string{
schemeName: {},
})
return s
}
// JSON returns the spec as JSON bytes.
func (s *OpenAPISpec) JSON() ([]byte, error) {
s.mu.RLock()
defer s.mu.RUnlock()
return json.MarshalIndent(s, "", " ")
}
// Op creates an OpenAPI operation helper.
func Op(summary, description string, tags ...string) map[string]any {
return map[string]any{
"summary": summary,
"description": description,
"tags": tags,
"responses": map[string]any{
"200": map[string]any{"description": "Success"},
},
}
}
// OpWithBody creates an OpenAPI operation with a request body.
func OpWithBody(summary, description string, tags ...string) map[string]any {
return map[string]any{
"summary": summary,
"description": description,
"tags": tags,
"requestBody": map[string]any{
"required": true,
"content": map[string]any{
"application/json": map[string]any{
"schema": map[string]any{
"type": "object",
},
},
},
},
"responses": map[string]any{
"200": map[string]any{"description": "Success"},
"201": map[string]any{"description": "Created"},
},
}
}

View File

@ -7,11 +7,12 @@ import "regexp"
type ComponentType string
const (
ComponentTypeService ComponentType = "service"
ComponentTypeWorker ComponentType = "worker"
ComponentTypeAppAstro ComponentType = "app-astro"
ComponentTypeAppReact ComponentType = "app-react"
ComponentTypeCLI ComponentType = "cli"
ComponentTypeService ComponentType = "service"
ComponentTypeWorker ComponentType = "worker"
ComponentTypeAppAstro ComponentType = "app-astro"
ComponentTypeAppReact ComponentType = "app-react"
ComponentTypeAppNextJS ComponentType = "app-nextjs"
ComponentTypeCLI ComponentType = "cli"
)
// ValidComponentTypes lists all valid component types.
@ -20,6 +21,7 @@ var ValidComponentTypes = []ComponentType{
ComponentTypeWorker,
ComponentTypeAppAstro,
ComponentTypeAppReact,
ComponentTypeAppNextJS,
ComponentTypeCLI,
}
@ -50,7 +52,7 @@ func (c ComponentType) DestDir() string {
return "services"
case ComponentTypeWorker:
return "workers"
case ComponentTypeAppAstro, ComponentTypeAppReact:
case ComponentTypeAppAstro, ComponentTypeAppReact, ComponentTypeAppNextJS:
return "apps"
case ComponentTypeCLI:
return "cli"
@ -65,7 +67,7 @@ func (c ComponentType) StartingPort() int {
switch c {
case ComponentTypeService:
return 8001
case ComponentTypeAppAstro, ComponentTypeAppReact:
case ComponentTypeAppAstro, ComponentTypeAppReact, ComponentTypeAppNextJS:
return 3001
case ComponentTypeWorker, ComponentTypeCLI:
return 0
@ -76,7 +78,7 @@ func (c ComponentType) StartingPort() int {
// NeedsPort returns true if this component type requires a port assignment.
func (c ComponentType) NeedsPort() bool {
return c == ComponentTypeService || c == ComponentTypeAppAstro || c == ComponentTypeAppReact
return c == ComponentTypeService || c == ComponentTypeAppAstro || c == ComponentTypeAppReact || c == ComponentTypeAppNextJS
}
// IsGoComponent returns true if this component type uses Go (and needs go.work entry).

View File

@ -12,6 +12,7 @@ func TestIsValidComponentType(t *testing.T) {
{"worker", "worker", true},
{"app-astro", "app-astro", true},
{"app-react", "app-react", true},
{"app-nextjs", "app-nextjs", true},
{"cli", "cli", true},
{"invalid", "invalid", false},
{"empty", "", false},
@ -39,6 +40,7 @@ func TestComponentType_DestDir(t *testing.T) {
{"worker", ComponentTypeWorker, "workers"},
{"app-astro", ComponentTypeAppAstro, "apps"},
{"app-react", ComponentTypeAppReact, "apps"},
{"app-nextjs", ComponentTypeAppNextJS, "apps"},
{"cli", ComponentTypeCLI, "cli"},
{"unknown", ComponentType("unknown"), ""},
}
@ -63,6 +65,7 @@ func TestComponentType_StartingPort(t *testing.T) {
{"worker", ComponentTypeWorker, 0},
{"app-astro", ComponentTypeAppAstro, 3001},
{"app-react", ComponentTypeAppReact, 3001},
{"app-nextjs", ComponentTypeAppNextJS, 3001},
{"cli", ComponentTypeCLI, 0},
{"unknown", ComponentType("unknown"), 0},
}
@ -87,6 +90,7 @@ func TestComponentType_NeedsPort(t *testing.T) {
{"worker", ComponentTypeWorker, false},
{"app-astro", ComponentTypeAppAstro, true},
{"app-react", ComponentTypeAppReact, true},
{"app-nextjs", ComponentTypeAppNextJS, true},
{"cli", ComponentTypeCLI, false},
}
@ -110,6 +114,7 @@ func TestComponentType_IsGoComponent(t *testing.T) {
{"worker", ComponentTypeWorker, true},
{"app-astro", ComponentTypeAppAstro, false},
{"app-react", ComponentTypeAppReact, false},
{"app-nextjs", ComponentTypeAppNextJS, false},
{"cli", ComponentTypeCLI, true},
}
@ -161,6 +166,7 @@ func TestValidComponentTypes(t *testing.T) {
ComponentTypeWorker,
ComponentTypeAppAstro,
ComponentTypeAppReact,
ComponentTypeAppNextJS,
ComponentTypeCLI,
}

129
internal/handlers/sdlc.go Normal file
View File

@ -0,0 +1,129 @@
package handlers
import (
"context"
"errors"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/sdlc"
"github.com/orchard9/rdev/internal/service"
"github.com/orchard9/rdev/pkg/api"
)
// SDLCHandler handles SDLC endpoints for project lifecycle management.
type SDLCHandler struct {
sdlcService *service.SDLCService
logger *slog.Logger
}
// NewSDLCHandler creates a new SDLC handler.
func NewSDLCHandler(sdlcService *service.SDLCService, logger *slog.Logger) *SDLCHandler {
if logger == nil {
logger = slog.Default()
}
return &SDLCHandler{
sdlcService: sdlcService,
logger: logger,
}
}
// Mount registers all SDLC routes under /projects/{id}/sdlc/.
func (h *SDLCHandler) Mount(r api.Router) {
r.Route("/projects/{id}/sdlc", func(r chi.Router) {
// State
r.Get("/state", h.GetState)
r.Get("/next", h.GetNext)
// Features
r.Get("/features", h.ListFeatures)
r.Post("/features", h.CreateFeature)
r.Get("/features/{slug}", h.GetFeature)
r.Post("/features/{slug}/transition", h.TransitionFeature)
r.Post("/features/{slug}/block", h.BlockFeature)
r.Post("/features/{slug}/unblock", h.UnblockFeature)
r.Delete("/features/{slug}", h.DeleteFeature)
// Artifacts
r.Get("/features/{slug}/artifacts", h.GetArtifactStatus)
r.Post("/features/{slug}/artifacts/{type}/approve", h.ApproveArtifact)
r.Post("/features/{slug}/artifacts/{type}/reject", h.RejectArtifact)
// Tasks
r.Get("/features/{slug}/tasks", h.ListTasks)
r.Post("/features/{slug}/tasks", h.AddTask)
r.Post("/features/{slug}/tasks/{taskId}/start", h.StartTask)
r.Post("/features/{slug}/tasks/{taskId}/complete", h.CompleteTask)
r.Post("/features/{slug}/tasks/{taskId}/block", h.BlockTask)
// Queries
r.Get("/query/blocked", h.QueryBlocked)
r.Get("/query/ready", h.QueryReady)
r.Get("/query/needs-approval", h.QueryNeedsApproval)
})
}
// GetState returns the global SDLC state for a project.
// GET /projects/{id}/sdlc/state
func (h *SDLCHandler) GetState(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
defer cancel()
state, err := h.sdlcService.GetState(ctx, projectID)
if err != nil {
writeSDLCError(w, r, err)
return
}
api.WriteSuccess(w, r, state)
}
// GetNext returns the classifier's recommendation for the next action.
// GET /projects/{id}/sdlc/next?feature=slug
func (h *SDLCHandler) GetNext(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
feature := r.URL.Query().Get("feature")
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
defer cancel()
cl, err := h.sdlcService.GetNext(ctx, projectID, feature)
if err != nil {
writeSDLCError(w, r, err)
return
}
api.WriteSuccess(w, r, cl)
}
// writeSDLCError maps SDLC domain errors to HTTP responses.
func writeSDLCError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, domain.ErrProjectNotFound):
api.WriteNotFound(w, r, "project not found")
case errors.Is(err, sdlc.ErrNotInitialized):
api.WriteNotFound(w, r, "sdlc not initialized for this project")
case errors.Is(err, sdlc.ErrFeatureNotFound):
api.WriteNotFound(w, r, "feature not found")
case errors.Is(err, sdlc.ErrTaskNotFound):
api.WriteNotFound(w, r, "task not found")
case errors.Is(err, sdlc.ErrArtifactNotFound):
api.WriteNotFound(w, r, "artifact not found")
case errors.Is(err, sdlc.ErrFeatureExists):
api.WriteBadRequest(w, r, "feature already exists")
case errors.Is(err, sdlc.ErrInvalidTransition):
api.WriteBadRequest(w, r, err.Error())
case errors.Is(err, sdlc.ErrInvalidPhase):
api.WriteBadRequest(w, r, "invalid phase")
case errors.Is(err, sdlc.ErrInvalidSlug):
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")
default:
api.WriteInternalError(w, r, "sdlc operation failed")
}
}

View File

@ -0,0 +1,84 @@
package handlers
import (
"context"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/sdlc"
"github.com/orchard9/rdev/pkg/api"
)
// GetArtifactStatus returns artifact statuses for a feature.
// GET /projects/{id}/sdlc/features/{slug}/artifacts
func (h *SDLCHandler) GetArtifactStatus(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()
artifacts, err := h.sdlcService.GetArtifactStatus(ctx, projectID, slug)
if err != nil {
writeSDLCError(w, r, err)
return
}
api.WriteSuccess(w, r, artifacts)
}
// ApproveArtifact approves a feature artifact.
// POST /projects/{id}/sdlc/features/{slug}/artifacts/{type}/approve
func (h *SDLCHandler) ApproveArtifact(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
slug := chi.URLParam(r, "slug")
artTypeStr := chi.URLParam(r, "type")
artType := sdlc.ArtifactType(artTypeStr)
if !sdlc.IsValidArtifactType(artType) {
api.WriteBadRequest(w, r, "invalid artifact type: "+artTypeStr)
return
}
ctx, cancel := context.WithTimeout(r.Context(), TimeoutHeavyWrite)
defer cancel()
if err := h.sdlcService.ApproveArtifact(ctx, projectID, slug, artType); err != nil {
writeSDLCError(w, r, err)
return
}
api.WriteSuccess(w, r, map[string]any{
"feature": slug,
"artifact": artTypeStr,
"status": "approved",
})
}
// RejectArtifact rejects a feature artifact.
// POST /projects/{id}/sdlc/features/{slug}/artifacts/{type}/reject
func (h *SDLCHandler) RejectArtifact(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
slug := chi.URLParam(r, "slug")
artTypeStr := chi.URLParam(r, "type")
artType := sdlc.ArtifactType(artTypeStr)
if !sdlc.IsValidArtifactType(artType) {
api.WriteBadRequest(w, r, "invalid artifact type: "+artTypeStr)
return
}
ctx, cancel := context.WithTimeout(r.Context(), TimeoutHeavyWrite)
defer cancel()
if err := h.sdlcService.RejectArtifact(ctx, projectID, slug, artType); err != nil {
writeSDLCError(w, r, err)
return
}
api.WriteSuccess(w, r, map[string]any{
"feature": slug,
"artifact": artTypeStr,
"status": "rejected",
})
}

View File

@ -0,0 +1,202 @@
package handlers
import (
"context"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/sdlc"
"github.com/orchard9/rdev/internal/validate"
"github.com/orchard9/rdev/pkg/api"
)
// CreateFeatureRequest is the request body for POST /projects/{id}/sdlc/features.
type CreateFeatureRequest struct {
Slug string `json:"slug"`
Title string `json:"title"`
}
// TransitionFeatureRequest is the request body for POST /projects/{id}/sdlc/features/{slug}/transition.
type TransitionFeatureRequest struct {
Phase string `json:"phase"`
}
// BlockFeatureRequest is the request body for POST /projects/{id}/sdlc/features/{slug}/block.
type BlockFeatureRequest struct {
Reason string `json:"reason"`
}
// ListFeatures returns all features in a project.
// GET /projects/{id}/sdlc/features
func (h *SDLCHandler) ListFeatures(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
defer cancel()
features, err := h.sdlcService.ListFeatures(ctx, projectID)
if err != nil {
writeSDLCError(w, r, err)
return
}
if features == nil {
features = []*sdlc.Feature{}
}
api.WriteSuccess(w, r, features)
}
// GetFeature returns a single feature by slug.
// GET /projects/{id}/sdlc/features/{slug}
func (h *SDLCHandler) GetFeature(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()
feature, err := h.sdlcService.GetFeature(ctx, projectID, slug)
if err != nil {
writeSDLCError(w, r, err)
return
}
api.WriteSuccess(w, r, feature)
}
// CreateFeature creates a new feature.
// POST /projects/{id}/sdlc/features
func (h *SDLCHandler) CreateFeature(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
var req CreateFeatureRequest
if err := api.DecodeJSON(r, &req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
v := validate.New()
v.Required(req.Slug, "slug")
v.Required(req.Title, "title")
if err := v.Error(); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
ctx, cancel := context.WithTimeout(r.Context(), TimeoutHeavyWrite)
defer cancel()
feature, err := h.sdlcService.CreateFeature(ctx, projectID, req.Slug, req.Title)
if err != nil {
writeSDLCError(w, r, err)
return
}
api.WriteCreated(w, r, feature)
}
// TransitionFeature moves a feature to a new phase.
// POST /projects/{id}/sdlc/features/{slug}/transition
func (h *SDLCHandler) TransitionFeature(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
slug := chi.URLParam(r, "slug")
var req TransitionFeatureRequest
if err := api.DecodeJSON(r, &req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
if req.Phase == "" {
api.WriteBadRequest(w, r, "phase is required")
return
}
phase := sdlc.FeaturePhase(req.Phase)
if !sdlc.IsValidPhase(phase) {
api.WriteBadRequest(w, r, "invalid phase: "+req.Phase)
return
}
ctx, cancel := context.WithTimeout(r.Context(), TimeoutHeavyWrite)
defer cancel()
if err := h.sdlcService.TransitionFeature(ctx, projectID, slug, phase); err != nil {
writeSDLCError(w, r, err)
return
}
api.WriteSuccess(w, r, map[string]any{
"feature": slug,
"phase": req.Phase,
"message": "feature transitioned successfully",
})
}
// BlockFeature blocks a feature with a reason.
// POST /projects/{id}/sdlc/features/{slug}/block
func (h *SDLCHandler) BlockFeature(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
slug := chi.URLParam(r, "slug")
var req BlockFeatureRequest
if err := api.DecodeJSON(r, &req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
if req.Reason == "" {
api.WriteBadRequest(w, r, "reason is required")
return
}
ctx, cancel := context.WithTimeout(r.Context(), TimeoutHeavyWrite)
defer cancel()
if err := h.sdlcService.BlockFeature(ctx, projectID, slug, req.Reason); err != nil {
writeSDLCError(w, r, err)
return
}
api.WriteSuccess(w, r, map[string]any{
"feature": slug,
"message": "feature blocked",
})
}
// UnblockFeature removes all blockers from a feature.
// POST /projects/{id}/sdlc/features/{slug}/unblock
func (h *SDLCHandler) UnblockFeature(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.UnblockFeature(ctx, projectID, slug); err != nil {
writeSDLCError(w, r, err)
return
}
api.WriteSuccess(w, r, map[string]any{
"feature": slug,
"message": "feature unblocked",
})
}
// DeleteFeature removes a feature.
// DELETE /projects/{id}/sdlc/features/{slug}
func (h *SDLCHandler) DeleteFeature(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.DeleteFeature(ctx, projectID, slug); err != nil {
writeSDLCError(w, r, err)
return
}
api.WriteNoContent(w)
}

View File

@ -0,0 +1,60 @@
package handlers
import (
"context"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/pkg/api"
)
// QueryBlocked returns all blocked features in a project.
// GET /projects/{id}/sdlc/query/blocked
func (h *SDLCHandler) QueryBlocked(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
defer cancel()
blocked, err := h.sdlcService.QueryBlocked(ctx, projectID)
if err != nil {
writeSDLCError(w, r, err)
return
}
api.WriteSuccess(w, r, blocked)
}
// QueryReady returns features ready for work in a project.
// GET /projects/{id}/sdlc/query/ready
func (h *SDLCHandler) QueryReady(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
defer cancel()
ready, err := h.sdlcService.QueryReady(ctx, projectID)
if err != nil {
writeSDLCError(w, r, err)
return
}
api.WriteSuccess(w, r, ready)
}
// QueryNeedsApproval returns features awaiting approval in a project.
// GET /projects/{id}/sdlc/query/needs-approval
func (h *SDLCHandler) QueryNeedsApproval(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
defer cancel()
pending, err := h.sdlcService.QueryNeedsApproval(ctx, projectID)
if err != nil {
writeSDLCError(w, r, err)
return
}
api.WriteSuccess(w, r, pending)
}

View File

@ -0,0 +1,134 @@
package handlers
import (
"context"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/sdlc"
"github.com/orchard9/rdev/internal/validate"
"github.com/orchard9/rdev/pkg/api"
)
// AddTaskRequest is the request body for POST /projects/{id}/sdlc/features/{slug}/tasks.
type AddTaskRequest struct {
Title string `json:"title"`
}
// ListTasks returns all tasks for a feature.
// GET /projects/{id}/sdlc/features/{slug}/tasks
func (h *SDLCHandler) ListTasks(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()
tasks, err := h.sdlcService.ListTasks(ctx, projectID, slug)
if err != nil {
writeSDLCError(w, r, err)
return
}
if tasks == nil {
tasks = []sdlc.Task{}
}
api.WriteSuccess(w, r, tasks)
}
// AddTask adds a new task to a feature.
// POST /projects/{id}/sdlc/features/{slug}/tasks
func (h *SDLCHandler) AddTask(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
slug := chi.URLParam(r, "slug")
var req AddTaskRequest
if err := api.DecodeJSON(r, &req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
v := validate.New()
v.Required(req.Title, "title")
if err := v.Error(); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
ctx, cancel := context.WithTimeout(r.Context(), TimeoutHeavyWrite)
defer cancel()
task, err := h.sdlcService.AddTask(ctx, projectID, slug, req.Title)
if err != nil {
writeSDLCError(w, r, err)
return
}
api.WriteCreated(w, r, task)
}
// StartTask marks a task as in-progress.
// POST /projects/{id}/sdlc/features/{slug}/tasks/{taskId}/start
func (h *SDLCHandler) StartTask(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
slug := chi.URLParam(r, "slug")
taskID := chi.URLParam(r, "taskId")
ctx, cancel := context.WithTimeout(r.Context(), TimeoutHeavyWrite)
defer cancel()
if err := h.sdlcService.StartTask(ctx, projectID, slug, taskID); err != nil {
writeSDLCError(w, r, err)
return
}
api.WriteSuccess(w, r, map[string]any{
"feature": slug,
"task_id": taskID,
"status": "in_progress",
})
}
// CompleteTask marks a task as complete.
// POST /projects/{id}/sdlc/features/{slug}/tasks/{taskId}/complete
func (h *SDLCHandler) CompleteTask(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
slug := chi.URLParam(r, "slug")
taskID := chi.URLParam(r, "taskId")
ctx, cancel := context.WithTimeout(r.Context(), TimeoutHeavyWrite)
defer cancel()
if err := h.sdlcService.CompleteTask(ctx, projectID, slug, taskID); err != nil {
writeSDLCError(w, r, err)
return
}
api.WriteSuccess(w, r, map[string]any{
"feature": slug,
"task_id": taskID,
"status": "complete",
})
}
// BlockTask marks a task as blocked.
// POST /projects/{id}/sdlc/features/{slug}/tasks/{taskId}/block
func (h *SDLCHandler) BlockTask(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
slug := chi.URLParam(r, "slug")
taskID := chi.URLParam(r, "taskId")
ctx, cancel := context.WithTimeout(r.Context(), TimeoutHeavyWrite)
defer cancel()
if err := h.sdlcService.BlockTask(ctx, projectID, slug, taskID); err != nil {
writeSDLCError(w, r, err)
return
}
api.WriteSuccess(w, r, map[string]any{
"feature": slug,
"task_id": taskID,
"status": "blocked",
})
}

View File

@ -0,0 +1,399 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
"github.com/orchard9/rdev/internal/sdlc"
"github.com/orchard9/rdev/internal/service"
)
// testSDLCExecutor implements port.SDLCExecutor for handler tests.
type testSDLCExecutor struct {
state *sdlc.State
classification *sdlc.Classification
features []*sdlc.Feature
feature *sdlc.Feature
artifacts map[sdlc.ArtifactType]*sdlc.Artifact
tasks []sdlc.Task
task *sdlc.Task
blocked []port.BlockedInfo
ready []port.ReadyInfo
approval []port.ApprovalInfo
err error
}
func (m *testSDLCExecutor) GetState(_ context.Context, _ string) (*sdlc.State, error) {
return m.state, m.err
}
func (m *testSDLCExecutor) GetNext(_ context.Context, _, _ string) (*sdlc.Classification, error) {
return m.classification, m.err
}
func (m *testSDLCExecutor) ListFeatures(_ context.Context, _ string) ([]*sdlc.Feature, error) {
return m.features, m.err
}
func (m *testSDLCExecutor) GetFeature(_ context.Context, _, _ string) (*sdlc.Feature, error) {
return m.feature, m.err
}
func (m *testSDLCExecutor) CreateFeature(_ context.Context, _, slug, title string) (*sdlc.Feature, error) {
if m.err != nil {
return nil, m.err
}
return &sdlc.Feature{Slug: slug, Title: title, Phase: sdlc.PhaseDraft}, nil
}
func (m *testSDLCExecutor) TransitionFeature(_ context.Context, _, _ string, _ sdlc.FeaturePhase) error {
return m.err
}
func (m *testSDLCExecutor) BlockFeature(_ context.Context, _, _, _ string) error { return m.err }
func (m *testSDLCExecutor) UnblockFeature(_ context.Context, _, _ string) error { return m.err }
func (m *testSDLCExecutor) DeleteFeature(_ context.Context, _, _ string) error { return m.err }
func (m *testSDLCExecutor) GetArtifactStatus(_ context.Context, _, _ string) (map[sdlc.ArtifactType]*sdlc.Artifact, error) {
return m.artifacts, m.err
}
func (m *testSDLCExecutor) ApproveArtifact(_ context.Context, _, _ string, _ sdlc.ArtifactType) error {
return m.err
}
func (m *testSDLCExecutor) RejectArtifact(_ context.Context, _, _ string, _ sdlc.ArtifactType) error {
return m.err
}
func (m *testSDLCExecutor) ListTasks(_ context.Context, _, _ string) ([]sdlc.Task, error) {
return m.tasks, m.err
}
func (m *testSDLCExecutor) AddTask(_ context.Context, _, _, title string) (*sdlc.Task, error) {
if m.err != nil {
return nil, m.err
}
if m.task != nil {
return m.task, nil
}
return &sdlc.Task{ID: "task-001", Title: title, Status: sdlc.TaskPending}, nil
}
func (m *testSDLCExecutor) StartTask(_ context.Context, _, _, _ string) error { return m.err }
func (m *testSDLCExecutor) CompleteTask(_ context.Context, _, _, _ string) error { return m.err }
func (m *testSDLCExecutor) BlockTask(_ context.Context, _, _, _ string) error { return m.err }
func (m *testSDLCExecutor) QueryBlocked(_ context.Context, _ string) ([]port.BlockedInfo, error) {
return m.blocked, m.err
}
func (m *testSDLCExecutor) QueryReady(_ context.Context, _ string) ([]port.ReadyInfo, error) {
return m.ready, m.err
}
func (m *testSDLCExecutor) QueryNeedsApproval(_ context.Context, _ string) ([]port.ApprovalInfo, error) {
return m.approval, m.err
}
// testSDLCProjectRepo implements port.ProjectRepository for handler tests.
type testSDLCProjectRepo struct {
project *domain.Project
}
func (m *testSDLCProjectRepo) Get(_ context.Context, _ domain.ProjectID) (*domain.Project, error) {
if m.project == nil {
return nil, domain.ErrProjectNotFound
}
return m.project, nil
}
func (m *testSDLCProjectRepo) List(_ context.Context) ([]domain.Project, error) { return nil, nil }
func (m *testSDLCProjectRepo) Exists(_ context.Context, _ domain.ProjectID) (bool, error) {
return m.project != nil, nil
}
func (m *testSDLCProjectRepo) Register(_ context.Context, _ *domain.Project) error { return nil }
func (m *testSDLCProjectRepo) Unregister(_ context.Context, _ domain.ProjectID) error { return nil }
func (m *testSDLCProjectRepo) RefreshStatus(_ context.Context) error { return nil }
func setupSDLCHandler(exec *testSDLCExecutor) (*SDLCHandler, *chi.Mux) {
repo := &testSDLCProjectRepo{
project: &domain.Project{ID: "test-project", PodName: "test-pod"},
}
svc := service.NewSDLCService(exec, repo, service.SDLCServiceConfig{})
handler := NewSDLCHandler(svc, nil)
r := chi.NewRouter()
handler.Mount(r)
return handler, r
}
func TestSDLCHandler_GetState(t *testing.T) {
exec := &testSDLCExecutor{
state: &sdlc.State{Version: 1},
}
_, router := setupSDLCHandler(exec)
req := httptest.NewRequest(http.MethodGet, "/projects/test-project/sdlc/state", 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_GetState_NotInitialized(t *testing.T) {
exec := &testSDLCExecutor{
err: sdlc.ErrNotInitialized,
}
_, router := setupSDLCHandler(exec)
req := httptest.NewRequest(http.MethodGet, "/projects/test-project/sdlc/state", 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_GetState_ProjectNotFound(t *testing.T) {
exec := &testSDLCExecutor{}
repo := &testSDLCProjectRepo{project: nil}
svc := service.NewSDLCService(exec, repo, service.SDLCServiceConfig{})
handler := NewSDLCHandler(svc, nil)
r := chi.NewRouter()
handler.Mount(r)
req := httptest.NewRequest(http.MethodGet, "/projects/nonexistent/sdlc/state", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected status 404, got %d: %s", w.Code, w.Body.String())
}
}
func TestSDLCHandler_CreateFeature(t *testing.T) {
exec := &testSDLCExecutor{}
_, router := setupSDLCHandler(exec)
body, _ := json.Marshal(CreateFeatureRequest{Slug: "auth-flow", Title: "Auth Flow"})
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
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_CreateFeature_MissingFields(t *testing.T) {
exec := &testSDLCExecutor{}
_, router := setupSDLCHandler(exec)
body, _ := json.Marshal(CreateFeatureRequest{})
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features", 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 TestSDLCHandler_CreateFeature_AlreadyExists(t *testing.T) {
exec := &testSDLCExecutor{err: sdlc.ErrFeatureExists}
_, router := setupSDLCHandler(exec)
body, _ := json.Marshal(CreateFeatureRequest{Slug: "auth-flow", Title: "Auth Flow"})
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features", 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 TestSDLCHandler_TransitionFeature(t *testing.T) {
exec := &testSDLCExecutor{}
_, router := setupSDLCHandler(exec)
body, _ := json.Marshal(TransitionFeatureRequest{Phase: "specified"})
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features/auth-flow/transition", 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_TransitionFeature_InvalidPhase(t *testing.T) {
exec := &testSDLCExecutor{}
_, router := setupSDLCHandler(exec)
body, _ := json.Marshal(TransitionFeatureRequest{Phase: "not-a-phase"})
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features/auth-flow/transition", 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 TestSDLCHandler_TransitionFeature_InvalidTransition(t *testing.T) {
exec := &testSDLCExecutor{err: sdlc.ErrInvalidTransition}
_, router := setupSDLCHandler(exec)
body, _ := json.Marshal(TransitionFeatureRequest{Phase: "review"})
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features/auth-flow/transition", 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 TestSDLCHandler_BlockFeature(t *testing.T) {
exec := &testSDLCExecutor{}
_, router := setupSDLCHandler(exec)
body, _ := json.Marshal(BlockFeatureRequest{Reason: "needs API key"})
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features/auth-flow/block", 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_BlockFeature_MissingReason(t *testing.T) {
exec := &testSDLCExecutor{}
_, router := setupSDLCHandler(exec)
body, _ := json.Marshal(BlockFeatureRequest{})
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features/auth-flow/block", 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 TestSDLCHandler_DeleteFeature(t *testing.T) {
exec := &testSDLCExecutor{}
_, router := setupSDLCHandler(exec)
req := httptest.NewRequest(http.MethodDelete, "/projects/test-project/sdlc/features/auth-flow", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNoContent {
t.Errorf("expected status 204, got %d: %s", w.Code, w.Body.String())
}
}
func TestSDLCHandler_DeleteFeature_NotFound(t *testing.T) {
exec := &testSDLCExecutor{err: sdlc.ErrFeatureNotFound}
_, router := setupSDLCHandler(exec)
req := httptest.NewRequest(http.MethodDelete, "/projects/test-project/sdlc/features/nonexistent", 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_ApproveArtifact(t *testing.T) {
exec := &testSDLCExecutor{}
_, router := setupSDLCHandler(exec)
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features/auth-flow/artifacts/spec/approve", 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_ApproveArtifact_InvalidType(t *testing.T) {
exec := &testSDLCExecutor{}
_, router := setupSDLCHandler(exec)
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features/auth-flow/artifacts/invalid/approve", 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_AddTask(t *testing.T) {
exec := &testSDLCExecutor{}
_, router := setupSDLCHandler(exec)
body, _ := json.Marshal(AddTaskRequest{Title: "Add login form"})
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features/auth-flow/tasks", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
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_AddTask_MissingTitle(t *testing.T) {
exec := &testSDLCExecutor{}
_, router := setupSDLCHandler(exec)
body, _ := json.Marshal(AddTaskRequest{})
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features/auth-flow/tasks", 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 TestSDLCHandler_QueryBlocked(t *testing.T) {
exec := &testSDLCExecutor{
blocked: []port.BlockedInfo{
{Slug: "auth", Phase: "implementation", Blockers: []string{"needs API key"}},
},
}
_, router := setupSDLCHandler(exec)
req := httptest.NewRequest(http.MethodGet, "/projects/test-project/sdlc/query/blocked", 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_InternalError(t *testing.T) {
exec := &testSDLCExecutor{err: errors.New("something unexpected")}
_, router := setupSDLCHandler(exec)
req := httptest.NewRequest(http.MethodGet, "/projects/test-project/sdlc/state", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected status 500, got %d: %s", w.Code, w.Body.String())
}
}

View File

@ -0,0 +1,93 @@
package port
import (
"context"
"github.com/orchard9/rdev/internal/sdlc"
)
// SDLCExecutor defines operations for executing SDLC commands in project pods.
// The adapter runs `sdlc` CLI commands via kubectl exec and parses JSON output.
type SDLCExecutor interface {
// GetState returns the global SDLC state for a project pod.
GetState(ctx context.Context, podName string) (*sdlc.State, error)
// GetNext returns the classifier's recommendation for the next action.
// If feature is empty, the classifier picks the most relevant feature.
GetNext(ctx context.Context, podName, feature string) (*sdlc.Classification, error)
// ListFeatures returns all features in the project.
ListFeatures(ctx context.Context, podName string) ([]*sdlc.Feature, error)
// GetFeature returns a single feature by slug.
GetFeature(ctx context.Context, podName, slug string) (*sdlc.Feature, error)
// CreateFeature creates a new feature with the given slug and title.
CreateFeature(ctx context.Context, podName, slug, title string) (*sdlc.Feature, error)
// TransitionFeature moves a feature to the specified phase.
TransitionFeature(ctx context.Context, podName, slug string, phase sdlc.FeaturePhase) error
// BlockFeature adds a blocker reason to a feature.
BlockFeature(ctx context.Context, podName, slug, reason string) error
// UnblockFeature removes all blockers from a feature.
UnblockFeature(ctx context.Context, podName, slug string) error
// DeleteFeature removes a feature entirely.
DeleteFeature(ctx context.Context, podName, slug string) error
// GetArtifactStatus returns artifact statuses for a feature.
GetArtifactStatus(ctx context.Context, podName, slug string) (map[sdlc.ArtifactType]*sdlc.Artifact, error)
// ApproveArtifact approves a feature artifact.
ApproveArtifact(ctx context.Context, podName, slug string, artType sdlc.ArtifactType) error
// RejectArtifact rejects a feature artifact.
RejectArtifact(ctx context.Context, podName, slug string, artType sdlc.ArtifactType) error
// ListTasks returns all tasks for a feature.
ListTasks(ctx context.Context, podName, slug string) ([]sdlc.Task, error)
// AddTask adds a new task to a feature.
AddTask(ctx context.Context, podName, slug, title string) (*sdlc.Task, error)
// StartTask marks a task as in-progress.
StartTask(ctx context.Context, podName, slug, taskID string) error
// CompleteTask marks a task as complete.
CompleteTask(ctx context.Context, podName, slug, taskID string) error
// BlockTask marks a task as blocked.
BlockTask(ctx context.Context, podName, slug, taskID string) error
// QueryBlocked returns all blocked features.
QueryBlocked(ctx context.Context, podName string) ([]BlockedInfo, error)
// QueryReady returns features ready for work.
QueryReady(ctx context.Context, podName string) ([]ReadyInfo, error)
// QueryNeedsApproval returns features awaiting approval.
QueryNeedsApproval(ctx context.Context, podName string) ([]ApprovalInfo, error)
}
// BlockedInfo describes a blocked feature (matches sdlc query --json output).
type BlockedInfo struct {
Slug string `json:"slug"`
Phase string `json:"phase"`
Blockers []string `json:"blockers"`
}
// ReadyInfo describes a feature ready for work (matches sdlc query --json output).
type ReadyInfo struct {
Slug string `json:"slug"`
Phase string `json:"phase"`
Action string `json:"action"`
}
// ApprovalInfo describes a feature awaiting approval (matches sdlc query --json output).
type ApprovalInfo struct {
Slug string `json:"slug"`
Phase string `json:"phase"`
Message string `json:"message"`
}

63
internal/sdlc/artifact.go Normal file
View File

@ -0,0 +1,63 @@
package sdlc
import "time"
// Artifact tracks the status of a feature artifact.
type Artifact struct {
Status ArtifactStatus `yaml:"status" json:"status"`
Path string `yaml:"path" json:"path"`
ApprovedBy string `yaml:"approved_by,omitempty" json:"approved_by,omitempty"`
ApprovedAt *time.Time `yaml:"approved_at,omitempty" json:"approved_at,omitempty"`
RejectedBy string `yaml:"rejected_by,omitempty" json:"rejected_by,omitempty"`
RejectedAt *time.Time `yaml:"rejected_at,omitempty" json:"rejected_at,omitempty"`
Total int `yaml:"total,omitempty" json:"total,omitempty"`
Completed int `yaml:"completed,omitempty" json:"completed,omitempty"`
InProgress int `yaml:"in_progress,omitempty" json:"in_progress,omitempty"`
Blocked int `yaml:"blocked,omitempty" json:"blocked,omitempty"`
}
// NewArtifact creates an artifact in pending status.
func NewArtifact(artifactType ArtifactType) *Artifact {
return &Artifact{
Status: StatusPending,
Path: ArtifactFilename(artifactType),
}
}
// Approve marks the artifact as approved.
func (a *Artifact) Approve(by string) {
now := time.Now().UTC()
a.Status = StatusApproved
a.ApprovedBy = by
a.ApprovedAt = &now
a.RejectedBy = ""
a.RejectedAt = nil
}
// Reject marks the artifact as rejected.
func (a *Artifact) Reject(by string) {
now := time.Now().UTC()
a.Status = StatusRejected
a.RejectedBy = by
a.RejectedAt = &now
}
// MarkDraft sets the artifact status to draft.
func (a *Artifact) MarkDraft() {
a.Status = StatusDraft
}
// MarkPassed sets the artifact status to passed.
func (a *Artifact) MarkPassed() {
a.Status = StatusPassed
}
// MarkFailed sets the artifact status to failed.
func (a *Artifact) MarkFailed() {
a.Status = StatusFailed
}
// MarkNeedsFix sets the artifact status to needs_fix.
func (a *Artifact) MarkNeedsFix() {
a.Status = StatusNeedsFix
}

View File

@ -0,0 +1,91 @@
package sdlc
import "time"
// Classification is the output of the classifier engine.
type Classification struct {
Timestamp time.Time `json:"timestamp" yaml:"timestamp"`
Feature string `json:"feature" yaml:"feature"`
CurrentPhase FeaturePhase `json:"current_phase" yaml:"current_phase"`
RuleMatched string `json:"rule_matched" yaml:"rule_matched"`
Action ActionType `json:"action" yaml:"action"`
Message string `json:"message" yaml:"message"`
NextCommand string `json:"next_command,omitempty" yaml:"next_command,omitempty"`
OutputPath string `json:"output_path,omitempty" yaml:"output_path,omitempty"`
TransitionTo FeaturePhase `json:"transition_to,omitempty" yaml:"transition_to,omitempty"`
TaskID string `json:"task_id,omitempty" yaml:"task_id,omitempty"`
}
// EvalContext provides all the state needed for rule evaluation.
type EvalContext struct {
State *State
Feature *Feature
Config *Config
Root string
}
// Rule is a single classifier rule with a condition and resulting action.
type Rule struct {
ID string
Condition func(ctx *EvalContext) bool
Action ActionType
Message func(ctx *EvalContext) string
NextCommand func(ctx *EvalContext) string
OutputPath func(ctx *EvalContext) string
TransitionTo FeaturePhase
TaskID func(ctx *EvalContext) string
}
// Classifier evaluates rules in priority order, returning the first match.
type Classifier struct {
rules []Rule
}
// NewClassifier creates a classifier with the default rules.
func NewClassifier() *Classifier {
return &Classifier{rules: DefaultRules()}
}
// NewClassifierWithRules creates a classifier with custom rules.
func NewClassifierWithRules(rules []Rule) *Classifier {
return &Classifier{rules: rules}
}
// Classify evaluates all rules against the context and returns the first match.
func (c *Classifier) Classify(ctx *EvalContext) *Classification {
for _, rule := range c.rules {
if rule.Condition(ctx) {
cl := &Classification{
Timestamp: time.Now().UTC(),
Feature: ctx.Feature.Slug,
CurrentPhase: ctx.Feature.Phase,
RuleMatched: rule.ID,
Action: rule.Action,
TransitionTo: rule.TransitionTo,
}
if rule.Message != nil {
cl.Message = rule.Message(ctx)
}
if rule.NextCommand != nil {
cl.NextCommand = rule.NextCommand(ctx)
}
if rule.OutputPath != nil {
cl.OutputPath = rule.OutputPath(ctx)
}
if rule.TaskID != nil {
cl.TaskID = rule.TaskID(ctx)
}
return cl
}
}
// Default: nothing to do
return &Classification{
Timestamp: time.Now().UTC(),
Feature: ctx.Feature.Slug,
CurrentPhase: ctx.Feature.Phase,
RuleMatched: "nothing-to-do",
Action: ActionIdle,
Message: "No actionable work found",
}
}

View File

@ -0,0 +1,493 @@
package sdlc
import "testing"
func makeTestFeature(phase FeaturePhase) *Feature {
f := &Feature{
Slug: "auth",
Title: "Auth",
Phase: phase,
Artifacts: map[ArtifactType]*Artifact{
ArtifactSpec: NewArtifact(ArtifactSpec),
ArtifactDesign: NewArtifact(ArtifactDesign),
ArtifactTasks: NewArtifact(ArtifactTasks),
ArtifactQAPlan: NewArtifact(ArtifactQAPlan),
ArtifactReview: NewArtifact(ArtifactReview),
ArtifactAudit: NewArtifact(ArtifactAudit),
ArtifactQAResults: NewArtifact(ArtifactQAResults),
},
}
return f
}
func TestClassifyDraftNeedsSpec(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhaseDraft)
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: DefaultConfig("test"),
})
if cl.Action != ActionCreateSpec {
t.Errorf("Action = %q, want CREATE_SPEC", cl.Action)
}
if cl.RuleMatched != "needs-spec" {
t.Errorf("RuleMatched = %q, want needs-spec", cl.RuleMatched)
}
}
func TestClassifyDraftSpecDraftNeedsApproval(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhaseDraft)
f.GetArtifact(ArtifactSpec).MarkDraft()
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: DefaultConfig("test"),
})
if cl.Action != ActionAwaitApproval {
t.Errorf("Action = %q, want AWAIT_APPROVAL", cl.Action)
}
if cl.RuleMatched != "spec-needs-approval" {
t.Errorf("RuleMatched = %q, want spec-needs-approval", cl.RuleMatched)
}
}
func TestClassifyDraftSpecApprovedTransition(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhaseDraft)
f.GetArtifact(ArtifactSpec).Approve("user")
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: DefaultConfig("test"),
})
if cl.Action != ActionTransition {
t.Errorf("Action = %q, want TRANSITION", cl.Action)
}
if cl.TransitionTo != PhaseSpecified {
t.Errorf("TransitionTo = %q, want specified", cl.TransitionTo)
}
}
func TestClassifySpecifiedNeedsDesign(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhaseSpecified)
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: DefaultConfig("test"),
})
if cl.Action != ActionCreateDesign {
t.Errorf("Action = %q, want CREATE_DESIGN", cl.Action)
}
}
func TestClassifySpecifiedNeedsTasks(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhaseSpecified)
f.GetArtifact(ArtifactDesign).Approve("user")
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: DefaultConfig("test"),
})
if cl.Action != ActionCreateTasks {
t.Errorf("Action = %q, want CREATE_TASKS", cl.Action)
}
}
func TestClassifySpecifiedNeedsQAPlan(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhaseSpecified)
f.GetArtifact(ArtifactDesign).Approve("user")
f.GetArtifact(ArtifactTasks).Approve("user")
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: DefaultConfig("test"),
})
if cl.Action != ActionCreateQAPlan {
t.Errorf("Action = %q, want CREATE_QA_PLAN", cl.Action)
}
}
func TestClassifySpecifiedPlanningComplete(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhaseSpecified)
f.GetArtifact(ArtifactDesign).Approve("user")
f.GetArtifact(ArtifactTasks).Approve("user")
f.GetArtifact(ArtifactQAPlan).Approve("user")
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: DefaultConfig("test"),
})
if cl.Action != ActionTransition {
t.Errorf("Action = %q, want TRANSITION", cl.Action)
}
if cl.TransitionTo != PhasePlanned {
t.Errorf("TransitionTo = %q, want planned", cl.TransitionTo)
}
}
func TestClassifyPlannedTransitionsToReady(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhasePlanned)
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: DefaultConfig("test"),
})
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 TestClassifyImplementationNextTask(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhaseImplementation)
f.Tasks = AddTask(nil, "Task 1")
f.Tasks = AddTask(f.Tasks, "Task 2")
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: DefaultConfig("test"),
})
if cl.Action != ActionImplementTask {
t.Errorf("Action = %q, want IMPLEMENT_TASK", cl.Action)
}
if cl.TaskID != "task-001" {
t.Errorf("TaskID = %q, want task-001", cl.TaskID)
}
}
func TestClassifyImplementationComplete(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhaseImplementation)
f.Tasks = AddTask(nil, "Task 1")
f.Tasks, _ = StartTask(f.Tasks, "task-001")
f.Tasks, _ = CompleteTask(f.Tasks, "task-001")
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: DefaultConfig("test"),
})
if cl.Action != ActionTransition {
t.Errorf("Action = %q, want TRANSITION", cl.Action)
}
if cl.TransitionTo != PhaseReview {
t.Errorf("TransitionTo = %q, want review", cl.TransitionTo)
}
}
func TestClassifyReviewNeeded(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhaseReview)
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: DefaultConfig("test"),
})
if cl.Action != ActionReviewCode {
t.Errorf("Action = %q, want REVIEW_CODE", cl.Action)
}
}
func TestClassifyReviewNeedsFix(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhaseReview)
f.GetArtifact(ArtifactReview).MarkNeedsFix()
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: DefaultConfig("test"),
})
if cl.Action != ActionFixReviewIssues {
t.Errorf("Action = %q, want FIX_REVIEW_ISSUES", cl.Action)
}
}
func TestClassifyReviewPassed(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhaseReview)
f.GetArtifact(ArtifactReview).MarkPassed()
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: DefaultConfig("test"),
})
if cl.Action != ActionTransition {
t.Errorf("Action = %q, want TRANSITION", cl.Action)
}
if cl.TransitionTo != PhaseAudit {
t.Errorf("TransitionTo = %q, want audit", cl.TransitionTo)
}
}
func TestClassifyAuditNeeded(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhaseAudit)
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: DefaultConfig("test"),
})
if cl.Action != ActionAuditCode {
t.Errorf("Action = %q, want AUDIT_CODE", cl.Action)
}
}
func TestClassifyAuditPassed(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhaseAudit)
f.GetArtifact(ArtifactAudit).MarkPassed()
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: DefaultConfig("test"),
})
if cl.Action != ActionTransition {
t.Errorf("Action = %q, want TRANSITION", cl.Action)
}
if cl.TransitionTo != PhaseQA {
t.Errorf("TransitionTo = %q, want qa", cl.TransitionTo)
}
}
func TestClassifyQANeeded(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhaseQA)
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: DefaultConfig("test"),
})
if cl.Action != ActionRunQA {
t.Errorf("Action = %q, want RUN_QA", cl.Action)
}
}
func TestClassifyQAPassed(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhaseQA)
f.GetArtifact(ArtifactQAResults).MarkPassed()
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: DefaultConfig("test"),
})
if cl.Action != ActionTransition {
t.Errorf("Action = %q, want TRANSITION", cl.Action)
}
if cl.TransitionTo != PhaseMerge {
t.Errorf("TransitionTo = %q, want merge", cl.TransitionTo)
}
}
func TestClassifyMerge(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhaseMerge)
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: DefaultConfig("test"),
})
if cl.Action != ActionMergeFeature {
t.Errorf("Action = %q, want MERGE_FEATURE", cl.Action)
}
}
func TestClassifyArchive(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhaseReleased)
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: DefaultConfig("test"),
})
if cl.Action != ActionArchive {
t.Errorf("Action = %q, want ARCHIVE", cl.Action)
}
}
func TestClassifyBlocked(t *testing.T) {
c := NewClassifier()
f := makeTestFeature(PhaseDraft)
f.AddBlocker("depends on payments")
cl := c.Classify(&EvalContext{
State: DefaultState("test"),
Feature: f,
Config: DefaultConfig("test"),
})
if cl.Action != ActionBlocked {
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)
}
}

115
internal/sdlc/config.go Normal file
View File

@ -0,0 +1,115 @@
package sdlc
import (
"fmt"
"os"
"slices"
"gopkg.in/yaml.v3"
)
// Config represents the project SDLC configuration in .sdlc/config.yaml.
type Config struct {
Version int `yaml:"version" json:"version"`
Project ProjectConfig `yaml:"project" json:"project"`
Branches BranchConfig `yaml:"branches" json:"branches"`
Phases PhaseConfig `yaml:"phases" json:"phases"`
Compliance ComplianceConfig `yaml:"compliance" json:"compliance"`
Patterns PatternsConfig `yaml:"patterns,omitempty" json:"patterns,omitempty"` //nolint:omitzero
}
// ProjectConfig holds project-level settings.
type ProjectConfig struct {
Name string `yaml:"name" json:"name"`
Type string `yaml:"type,omitempty" json:"type,omitempty"`
}
// BranchConfig defines branch naming conventions.
type BranchConfig struct {
Main string `yaml:"main" json:"main"`
FeaturePrefix string `yaml:"feature_prefix" json:"feature_prefix"`
}
// PhaseConfig defines which phases are enabled and what artifacts are required.
type PhaseConfig struct {
Enabled []FeaturePhase `yaml:"enabled" json:"enabled"`
RequiredArtifacts map[FeaturePhase][]ArtifactType `yaml:"required_artifacts" json:"required_artifacts"`
}
// ComplianceConfig defines approval and gate requirements.
type ComplianceConfig struct {
RequireApprovals bool `yaml:"require_approvals" json:"require_approvals"`
RequireBranch bool `yaml:"require_branch" json:"require_branch"`
RequireQA bool `yaml:"require_qa" json:"require_qa"`
}
// PatternsConfig defines pattern enforcement.
type PatternsConfig struct {
AutoEnforce []string `yaml:"auto_enforce,omitempty" json:"auto_enforce,omitempty"`
}
// LoadConfig reads and parses .sdlc/config.yaml from the given project root.
func LoadConfig(root string) (*Config, error) {
path := ConfigPath(root)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, ErrNotInitialized
}
return nil, fmt.Errorf("read config file: %w", err)
}
var c Config
if err := yaml.Unmarshal(data, &c); err != nil {
return nil, fmt.Errorf("parse config file: %w", err)
}
return &c, nil
}
// Save writes the config to .sdlc/config.yaml.
func (c *Config) Save(root string) error {
data, err := yaml.Marshal(c)
if err != nil {
return fmt.Errorf("marshal config: %w", err)
}
path := ConfigPath(root)
if err := os.WriteFile(path, data, 0o644); err != nil {
return fmt.Errorf("write config file: %w", err)
}
return nil
}
// DefaultConfig returns a config with all phases enabled and standard requirements.
func DefaultConfig(projectName string) *Config {
return &Config{
Version: 1,
Project: ProjectConfig{
Name: projectName,
},
Branches: BranchConfig{
Main: "main",
FeaturePrefix: "feature/",
},
Phases: PhaseConfig{
Enabled: ValidPhases,
RequiredArtifacts: map[FeaturePhase][]ArtifactType{
PhaseSpecified: {ArtifactSpec},
PhasePlanned: {ArtifactSpec, ArtifactDesign, ArtifactTasks, ArtifactQAPlan},
PhaseReview: {ArtifactReview},
PhaseAudit: {ArtifactAudit},
PhaseQA: {ArtifactQAResults},
},
},
Compliance: ComplianceConfig{
RequireApprovals: true,
RequireBranch: true,
RequireQA: true,
},
}
}
// IsPhaseEnabled returns true if the phase is in the enabled list.
func (c *Config) IsPhaseEnabled(phase FeaturePhase) bool {
return slices.Contains(c.Phases.Enabled, phase)
}

View File

@ -0,0 +1,76 @@
package sdlc
import (
"os"
"testing"
)
func TestConfigRoundTrip(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(SDLCRoot(root), 0o755); err != nil {
t.Fatal(err)
}
original := DefaultConfig("test-project")
if err := original.Save(root); err != nil {
t.Fatalf("Save: %v", err)
}
loaded, err := LoadConfig(root)
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
if loaded.Version != 1 {
t.Errorf("Version = %d, want 1", loaded.Version)
}
if loaded.Project.Name != "test-project" {
t.Errorf("Project.Name = %q, want %q", loaded.Project.Name, "test-project")
}
if loaded.Branches.Main != "main" {
t.Errorf("Branches.Main = %q, want main", loaded.Branches.Main)
}
if loaded.Branches.FeaturePrefix != "feature/" {
t.Errorf("Branches.FeaturePrefix = %q, want feature/", loaded.Branches.FeaturePrefix)
}
if len(loaded.Phases.Enabled) != len(ValidPhases) {
t.Errorf("Phases.Enabled len = %d, want %d", len(loaded.Phases.Enabled), len(ValidPhases))
}
}
func TestDefaultConfigRequiredArtifacts(t *testing.T) {
c := DefaultConfig("test")
// specified phase requires spec
arts, ok := c.Phases.RequiredArtifacts[PhaseSpecified]
if !ok || len(arts) != 1 || arts[0] != ArtifactSpec {
t.Errorf("RequiredArtifacts[specified] = %v, want [spec]", arts)
}
// planned phase requires 4 artifacts
arts, ok = c.Phases.RequiredArtifacts[PhasePlanned]
if !ok || len(arts) != 4 {
t.Errorf("RequiredArtifacts[planned] len = %d, want 4", len(arts))
}
}
func TestIsPhaseEnabled(t *testing.T) {
c := DefaultConfig("test")
if !c.IsPhaseEnabled(PhaseDraft) {
t.Error("IsPhaseEnabled(draft) = false, want true")
}
if c.IsPhaseEnabled("bogus") {
t.Error("IsPhaseEnabled(bogus) = true, want false")
}
}
func TestLoadConfigNotInitialized(t *testing.T) {
root := t.TempDir()
_, err := LoadConfig(root)
if err != ErrNotInitialized {
t.Errorf("LoadConfig = %v, want ErrNotInitialized", err)
}
}

16
internal/sdlc/errors.go Normal file
View File

@ -0,0 +1,16 @@
package sdlc
import "errors"
var (
ErrNotInitialized = errors.New("sdlc not initialized: run 'sdlc init'")
ErrFeatureNotFound = errors.New("feature not found")
ErrFeatureExists = errors.New("feature already exists")
ErrInvalidSlug = errors.New("invalid slug: must be lowercase alphanumeric with hyphens")
ErrInvalidTransition = errors.New("invalid phase transition")
ErrInvalidPhase = errors.New("invalid phase")
ErrInvalidArtifact = errors.New("invalid artifact type")
ErrTaskNotFound = errors.New("task not found")
ErrArtifactNotFound = errors.New("artifact not found")
ErrNoFeatures = errors.New("no features found")
)

267
internal/sdlc/feature.go Normal file
View File

@ -0,0 +1,267 @@
package sdlc
import (
"fmt"
"os"
"path/filepath"
"time"
"gopkg.in/yaml.v3"
)
// Feature represents a feature under development, stored in manifest.yaml.
type Feature struct {
Slug string `yaml:"slug" json:"slug"`
Title string `yaml:"title" json:"title"`
Created time.Time `yaml:"created" json:"created"`
Branch string `yaml:"branch,omitempty" json:"branch,omitempty"`
RoadmapRef string `yaml:"roadmap_ref,omitempty" json:"roadmap_ref,omitempty"`
Phase FeaturePhase `yaml:"phase" json:"phase"`
PhaseHistory []PhaseTransition `yaml:"phase_history" json:"phase_history"`
Artifacts map[ArtifactType]*Artifact `yaml:"artifacts" json:"artifacts"`
Tasks []Task `yaml:"tasks,omitempty" json:"tasks,omitempty"`
Blockers []string `yaml:"blockers,omitempty" json:"blockers,omitempty"`
Dependencies Dependencies `yaml:"dependencies,omitempty" json:"dependencies,omitempty"` //nolint:omitzero
}
// CreateFeature creates a new feature directory and manifest.
func CreateFeature(root, slug, title string) (*Feature, error) {
if err := ValidateSlug(slug); err != nil {
return nil, err
}
dir := FeatureDir(root, slug)
if _, err := os.Stat(dir); err == nil {
return nil, ErrFeatureExists
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("create feature directory: %w", err)
}
now := time.Now().UTC()
f := &Feature{
Slug: slug,
Title: title,
Created: now,
Phase: PhaseDraft,
PhaseHistory: []PhaseTransition{
{Phase: PhaseDraft, Entered: now},
},
Artifacts: map[ArtifactType]*Artifact{
ArtifactSpec: NewArtifact(ArtifactSpec),
ArtifactDesign: NewArtifact(ArtifactDesign),
ArtifactTasks: NewArtifact(ArtifactTasks),
ArtifactQAPlan: NewArtifact(ArtifactQAPlan),
ArtifactReview: NewArtifact(ArtifactReview),
ArtifactAudit: NewArtifact(ArtifactAudit),
ArtifactQAResults: NewArtifact(ArtifactQAResults),
},
}
if err := f.Save(root); err != nil {
return nil, err
}
return f, nil
}
// LoadFeature reads a feature manifest from disk.
func LoadFeature(root, slug string) (*Feature, error) {
path := ManifestPath(root, slug)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, ErrFeatureNotFound
}
return nil, fmt.Errorf("read feature manifest: %w", err)
}
var f Feature
if err := yaml.Unmarshal(data, &f); err != nil {
return nil, fmt.Errorf("parse feature manifest: %w", err)
}
return &f, nil
}
// Save writes the feature manifest to disk.
func (f *Feature) Save(root string) error {
data, err := yaml.Marshal(f)
if err != nil {
return fmt.Errorf("marshal feature manifest: %w", err)
}
path := ManifestPath(root, f.Slug)
if err := os.WriteFile(path, data, 0o644); err != nil {
return fmt.Errorf("write feature manifest: %w", err)
}
return nil
}
// ListFeatures returns all features found in .sdlc/features/.
func ListFeatures(root string) ([]*Feature, error) {
dir := FeaturesDirPath(root)
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, ErrNotInitialized
}
return nil, fmt.Errorf("read features directory: %w", err)
}
var features []*Feature
for _, entry := range entries {
if !entry.IsDir() {
continue
}
manifestPath := filepath.Join(dir, entry.Name(), ManifestFile)
if _, err := os.Stat(manifestPath); os.IsNotExist(err) {
continue
}
f, err := LoadFeature(root, entry.Name())
if err != nil {
continue
}
features = append(features, f)
}
return features, nil
}
// CanTransitionTo checks if the feature can move to the target phase.
// It validates ordering, config allowances, required artifacts, and blockers.
func (f *Feature) CanTransitionTo(target FeaturePhase, cfg *Config) error {
if !IsValidPhase(target) {
return fmt.Errorf("%w: %s is not a valid phase", ErrInvalidPhase, target)
}
if cfg != nil && !cfg.IsPhaseEnabled(target) {
return fmt.Errorf("%w: phase %s is not enabled", ErrInvalidTransition, target)
}
currentIdx := PhaseIndex(f.Phase)
targetIdx := PhaseIndex(target)
if targetIdx <= currentIdx {
return fmt.Errorf("%w: cannot move from %s to %s (backward)", ErrInvalidTransition, f.Phase, target)
}
// Only allow moving to the next phase (no skipping)
if targetIdx != currentIdx+1 {
return fmt.Errorf("%w: cannot skip from %s to %s", ErrInvalidTransition, f.Phase, target)
}
// Check required artifacts for the target phase are approved/passed
if cfg != nil {
required, ok := cfg.Phases.RequiredArtifacts[target]
if ok {
for _, artType := range required {
art, exists := f.Artifacts[artType]
if !exists {
return fmt.Errorf("%w: missing required artifact %s to enter phase %s", ErrInvalidTransition, artType, target)
}
if art.Status != StatusApproved && art.Status != StatusPassed {
return fmt.Errorf("%w: artifact %s is %s, must be approved or passed to enter %s", ErrInvalidTransition, artType, art.Status, target)
}
}
}
}
// Check no blockers
if len(f.Blockers) > 0 {
return fmt.Errorf("%w: feature has %d blocker(s)", ErrInvalidTransition, len(f.Blockers))
}
return nil
}
// Transition moves the feature to the target phase, recording history.
func (f *Feature) Transition(target FeaturePhase) error {
now := time.Now().UTC()
// Close the current phase history entry
if len(f.PhaseHistory) > 0 {
f.PhaseHistory[len(f.PhaseHistory)-1].Exited = &now
}
f.Phase = target
f.PhaseHistory = append(f.PhaseHistory, PhaseTransition{
Phase: target,
Entered: now,
})
return nil
}
// AddBlocker adds a blocker reason.
func (f *Feature) AddBlocker(reason string) {
f.Blockers = append(f.Blockers, reason)
}
// ClearBlockers removes all blockers.
func (f *Feature) ClearBlockers() {
f.Blockers = nil
}
// IsBlocked returns true if the feature has any blockers.
func (f *Feature) IsBlocked() bool {
return len(f.Blockers) > 0
}
// GetArtifact returns the artifact by type, or nil.
func (f *Feature) GetArtifact(t ArtifactType) *Artifact {
if f.Artifacts == nil {
return nil
}
return f.Artifacts[t]
}
// SetArtifact sets or creates an artifact entry.
func (f *Feature) SetArtifact(t ArtifactType, a *Artifact) {
if f.Artifacts == nil {
f.Artifacts = make(map[ArtifactType]*Artifact)
}
f.Artifacts[t] = a
}
// ArtifactFileExists checks if the artifact file exists on disk.
func (f *Feature) ArtifactFileExists(root string, artType ArtifactType) bool {
path := ArtifactPath(root, f.Slug, artType)
if path == "" {
return false
}
_, err := os.Stat(path)
return err == nil
}
// DeleteFeature removes a feature directory and all contents.
func DeleteFeature(root, slug string) error {
dir := FeatureDir(root, slug)
if _, err := os.Stat(dir); os.IsNotExist(err) {
return ErrFeatureNotFound
}
return os.RemoveAll(dir)
}
// ArchiveFeature moves a feature from features/ to archives/.
func ArchiveFeature(root, slug string) error {
src := FeatureDir(root, slug)
if _, err := os.Stat(src); os.IsNotExist(err) {
return ErrFeatureNotFound
}
dst := filepath.Join(SDLCRoot(root), ArchivesDir, slug)
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return fmt.Errorf("create archives directory: %w", err)
}
return os.Rename(src, dst)
}
// UpdateTaskSummary refreshes the tasks artifact with current counts.
func (f *Feature) UpdateTaskSummary() {
summary := SummarizeTasks(f.Tasks)
if art := f.GetArtifact(ArtifactTasks); art != nil {
art.Total = summary.Total
art.Completed = summary.Completed
art.InProgress = summary.InProgress
art.Blocked = summary.Blocked
}
}

View File

@ -0,0 +1,313 @@
package sdlc
import (
"errors"
"testing"
)
func setupInitializedRoot(t *testing.T) string {
t.Helper()
root := t.TempDir()
if err := Init(root, "test-project"); err != nil {
t.Fatalf("Init: %v", err)
}
return root
}
func TestCreateFeature(t *testing.T) {
root := setupInitializedRoot(t)
f, err := CreateFeature(root, "auth", "User Authentication")
if err != nil {
t.Fatalf("CreateFeature: %v", err)
}
if f.Slug != "auth" {
t.Errorf("Slug = %q, want auth", f.Slug)
}
if f.Title != "User Authentication" {
t.Errorf("Title = %q, want User Authentication", f.Title)
}
if f.Phase != PhaseDraft {
t.Errorf("Phase = %q, want draft", f.Phase)
}
if len(f.PhaseHistory) != 1 {
t.Errorf("PhaseHistory len = %d, want 1", len(f.PhaseHistory))
}
if len(f.Artifacts) != 7 {
t.Errorf("Artifacts len = %d, want 7", len(f.Artifacts))
}
}
func TestCreateFeatureDuplicate(t *testing.T) {
root := setupInitializedRoot(t)
if _, err := CreateFeature(root, "auth", "Auth"); err != nil {
t.Fatalf("CreateFeature: %v", err)
}
_, err := CreateFeature(root, "auth", "Auth Again")
if !errors.Is(err, ErrFeatureExists) {
t.Errorf("err = %v, want ErrFeatureExists", err)
}
}
func TestCreateFeatureInvalidSlug(t *testing.T) {
root := setupInitializedRoot(t)
_, err := CreateFeature(root, "INVALID", "Bad Slug")
if !errors.Is(err, ErrInvalidSlug) {
t.Errorf("err = %v, want ErrInvalidSlug", err)
}
}
func TestLoadFeature(t *testing.T) {
root := setupInitializedRoot(t)
if _, err := CreateFeature(root, "auth", "Auth"); err != nil {
t.Fatalf("CreateFeature: %v", err)
}
loaded, err := LoadFeature(root, "auth")
if err != nil {
t.Fatalf("LoadFeature: %v", err)
}
if loaded.Slug != "auth" {
t.Errorf("Slug = %q, want auth", loaded.Slug)
}
if loaded.Phase != PhaseDraft {
t.Errorf("Phase = %q, want draft", loaded.Phase)
}
}
func TestLoadFeatureNotFound(t *testing.T) {
root := setupInitializedRoot(t)
_, err := LoadFeature(root, "nonexistent")
if !errors.Is(err, ErrFeatureNotFound) {
t.Errorf("err = %v, want ErrFeatureNotFound", err)
}
}
func TestListFeatures(t *testing.T) {
root := setupInitializedRoot(t)
if _, err := CreateFeature(root, "auth", "Auth"); err != nil {
t.Fatal(err)
}
if _, err := CreateFeature(root, "payments", "Payments"); err != nil {
t.Fatal(err)
}
features, err := ListFeatures(root)
if err != nil {
t.Fatalf("ListFeatures: %v", err)
}
if len(features) != 2 {
t.Errorf("ListFeatures len = %d, want 2", len(features))
}
}
func TestCanTransitionTo(t *testing.T) {
cfg := DefaultConfig("test")
f := &Feature{
Phase: PhaseDraft,
Artifacts: map[ArtifactType]*Artifact{
ArtifactSpec: {Status: StatusApproved},
},
}
// Valid: draft -> specified (spec is approved)
if err := f.CanTransitionTo(PhaseSpecified, cfg); err != nil {
t.Errorf("CanTransitionTo(specified) = %v, want nil", err)
}
// Invalid: draft -> planned (skip)
if err := f.CanTransitionTo(PhasePlanned, cfg); err == nil {
t.Error("CanTransitionTo(planned) = nil, want error (skip)")
}
// Invalid: draft -> draft (backward)
if err := f.CanTransitionTo(PhaseDraft, cfg); err == nil {
t.Error("CanTransitionTo(draft) = nil, want error (backward)")
}
// Invalid phase
if err := f.CanTransitionTo("bogus", cfg); err == nil {
t.Error("CanTransitionTo(bogus) = nil, want error")
}
// Without config, artifact checks are skipped
bare := &Feature{Phase: PhaseDraft}
if err := bare.CanTransitionTo(PhaseSpecified, nil); err != nil {
t.Errorf("CanTransitionTo(specified) without config = %v, want nil", err)
}
}
func TestTransition(t *testing.T) {
f := &Feature{
Phase: PhaseDraft,
PhaseHistory: []PhaseTransition{
{Phase: PhaseDraft},
},
}
if err := f.Transition(PhaseSpecified); err != nil {
t.Fatalf("Transition: %v", err)
}
if f.Phase != PhaseSpecified {
t.Errorf("Phase = %q, want specified", f.Phase)
}
if len(f.PhaseHistory) != 2 {
t.Fatalf("PhaseHistory len = %d, want 2", len(f.PhaseHistory))
}
if f.PhaseHistory[0].Exited == nil {
t.Error("PhaseHistory[0].Exited is nil, want set")
}
if f.PhaseHistory[1].Phase != PhaseSpecified {
t.Errorf("PhaseHistory[1].Phase = %q, want specified", f.PhaseHistory[1].Phase)
}
}
func TestFeatureManifestRoundTrip(t *testing.T) {
root := setupInitializedRoot(t)
f, err := CreateFeature(root, "auth", "Auth")
if err != nil {
t.Fatal(err)
}
// Modify and save
f.Branch = "feature/auth"
if err := f.Transition(PhaseSpecified); err != nil {
t.Fatal(err)
}
f.GetArtifact(ArtifactSpec).Approve("user")
f.Tasks = AddTask(nil, "Create user model")
f.UpdateTaskSummary()
if err := f.Save(root); err != nil {
t.Fatalf("Save: %v", err)
}
loaded, err := LoadFeature(root, "auth")
if err != nil {
t.Fatalf("LoadFeature: %v", err)
}
if loaded.Branch != "feature/auth" {
t.Errorf("Branch = %q, want feature/auth", loaded.Branch)
}
if loaded.Phase != PhaseSpecified {
t.Errorf("Phase = %q, want specified", loaded.Phase)
}
if loaded.GetArtifact(ArtifactSpec).Status != StatusApproved {
t.Errorf("Spec status = %q, want approved", loaded.GetArtifact(ArtifactSpec).Status)
}
if len(loaded.Tasks) != 1 {
t.Errorf("Tasks len = %d, want 1", len(loaded.Tasks))
}
}
func TestCanTransitionToRequiredArtifacts(t *testing.T) {
cfg := DefaultConfig("test")
// draft -> specified requires spec to be approved
f := &Feature{
Phase: PhaseDraft,
Artifacts: map[ArtifactType]*Artifact{
ArtifactSpec: {Status: StatusPending},
},
}
err := f.CanTransitionTo(PhaseSpecified, cfg)
if err == nil {
t.Error("CanTransitionTo(specified) should fail with unapproved spec")
}
// Approve spec - now draft -> specified should work
f.Artifacts[ArtifactSpec].Approve("user")
err = f.CanTransitionTo(PhaseSpecified, cfg)
if err != nil {
t.Errorf("CanTransitionTo(specified) with approved spec: %v", err)
}
// specified -> planned requires spec, design, tasks, qa_plan
f.Phase = PhaseSpecified
err = f.CanTransitionTo(PhasePlanned, cfg)
if err == nil {
t.Error("CanTransitionTo(planned) should fail with missing design/tasks/qa_plan")
}
}
func TestCanTransitionToBlockersPrevent(t *testing.T) {
cfg := DefaultConfig("test")
f := &Feature{
Phase: PhaseDraft,
Blockers: []string{"dependency on payments"},
}
err := f.CanTransitionTo(PhaseSpecified, cfg)
if err == nil {
t.Error("CanTransitionTo should fail when feature has blockers")
}
}
func TestDeleteFeature(t *testing.T) {
root := setupInitializedRoot(t)
if _, err := CreateFeature(root, "auth", "Auth"); err != nil {
t.Fatal(err)
}
if err := DeleteFeature(root, "auth"); err != nil {
t.Fatalf("DeleteFeature: %v", err)
}
_, err := LoadFeature(root, "auth")
if err != ErrFeatureNotFound {
t.Errorf("LoadFeature after delete: %v, want ErrFeatureNotFound", err)
}
}
func TestDeleteFeatureNotFound(t *testing.T) {
root := setupInitializedRoot(t)
if err := DeleteFeature(root, "nonexistent"); err != ErrFeatureNotFound {
t.Errorf("DeleteFeature = %v, want ErrFeatureNotFound", err)
}
}
func TestArtifactFileExists(t *testing.T) {
root := setupInitializedRoot(t)
f, err := CreateFeature(root, "auth", "Auth")
if err != nil {
t.Fatal(err)
}
// No spec file exists yet
if f.ArtifactFileExists(root, ArtifactSpec) {
t.Error("ArtifactFileExists(spec) = true before file creation")
}
}
func TestBlockers(t *testing.T) {
f := &Feature{}
if f.IsBlocked() {
t.Error("IsBlocked = true, want false")
}
f.AddBlocker("dependency on auth")
if !f.IsBlocked() {
t.Error("IsBlocked = false, want true")
}
f.ClearBlockers()
if f.IsBlocked() {
t.Error("IsBlocked = true after clear, want false")
}
}

44
internal/sdlc/history.go Normal file
View File

@ -0,0 +1,44 @@
package sdlc
import "time"
// HistoryEntry records a single action taken in the SDLC lifecycle.
type HistoryEntry struct {
Timestamp time.Time `yaml:"timestamp" json:"timestamp"`
Action string `yaml:"action" json:"action"`
Feature string `yaml:"feature,omitempty" json:"feature,omitempty"`
Actor string `yaml:"actor" json:"actor"`
Result string `yaml:"result,omitempty" json:"result,omitempty"`
Output string `yaml:"output,omitempty" json:"output,omitempty"`
FromPhase string `yaml:"from_phase,omitempty" json:"from_phase,omitempty"`
ToPhase string `yaml:"to_phase,omitempty" json:"to_phase,omitempty"`
}
// BlockedItem represents something that is blocked in the SDLC.
type BlockedItem struct {
Type string `yaml:"type" json:"type"`
Slug string `yaml:"slug" json:"slug"`
Reason string `yaml:"reason" json:"reason"`
Since string `yaml:"since,omitempty" json:"since,omitempty"`
RuleID string `yaml:"rule_id,omitempty" json:"rule_id,omitempty"`
}
// ActiveFeature tracks a feature in active_work.
type ActiveFeature struct {
Slug string `yaml:"slug" json:"slug"`
Branch string `yaml:"branch,omitempty" json:"branch,omitempty"`
Phase FeaturePhase `yaml:"phase" json:"phase"`
}
// ActiveWork tracks all active items.
type ActiveWork struct {
Features []ActiveFeature `yaml:"features" json:"features"`
Patterns []string `yaml:"patterns,omitempty" json:"patterns,omitempty"`
Audits []string `yaml:"audits,omitempty" json:"audits,omitempty"`
}
// ProjectState holds project-level metadata.
type ProjectState struct {
Name string `yaml:"name" json:"name"`
CurrentRoadmap string `yaml:"current_roadmap,omitempty" json:"current_roadmap,omitempty"`
}

45
internal/sdlc/init.go Normal file
View File

@ -0,0 +1,45 @@
package sdlc
import (
"fmt"
"os"
"path/filepath"
)
// Init creates the .sdlc/ directory structure and default files.
func Init(root, projectName string) error {
sdlcRoot := SDLCRoot(root)
// Check if already initialized
if _, err := os.Stat(sdlcRoot); err == nil {
return fmt.Errorf("already initialized: %s exists", sdlcRoot)
}
// Create .sdlc/ and all subdirectories
for _, dir := range SubDirs() {
dirPath := filepath.Join(sdlcRoot, dir)
if err := os.MkdirAll(dirPath, 0o755); err != nil {
return fmt.Errorf("create directory %s: %w", dir, err)
}
}
// Write default state
state := DefaultState(projectName)
if err := state.Save(root); err != nil {
return fmt.Errorf("write default state: %w", err)
}
// Write default config
config := DefaultConfig(projectName)
if err := config.Save(root); err != nil {
return fmt.Errorf("write default config: %w", err)
}
return nil
}
// IsInitialized returns true if the .sdlc/ directory exists.
func IsInitialized(root string) bool {
info, err := os.Stat(SDLCRoot(root))
return err == nil && info.IsDir()
}

View File

@ -0,0 +1,88 @@
package sdlc
import (
"os"
"path/filepath"
"testing"
)
func TestInit(t *testing.T) {
root := t.TempDir()
if err := Init(root, "test-project"); err != nil {
t.Fatalf("Init: %v", err)
}
// Verify .sdlc directory exists
sdlcRoot := SDLCRoot(root)
info, err := os.Stat(sdlcRoot)
if err != nil {
t.Fatalf("stat .sdlc: %v", err)
}
if !info.IsDir() {
t.Fatal(".sdlc is not a directory")
}
// Verify all subdirectories exist
for _, dir := range SubDirs() {
dirPath := filepath.Join(sdlcRoot, dir)
info, err := os.Stat(dirPath)
if err != nil {
t.Errorf("stat %s: %v", dir, err)
continue
}
if !info.IsDir() {
t.Errorf("%s is not a directory", dir)
}
}
// Verify state.yaml
state, err := LoadState(root)
if err != nil {
t.Fatalf("LoadState: %v", err)
}
if state.Version != 1 {
t.Errorf("state.Version = %d, want 1", state.Version)
}
if state.Project.Name != "test-project" {
t.Errorf("state.Project.Name = %q, want test-project", state.Project.Name)
}
// Verify config.yaml
config, err := LoadConfig(root)
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
if config.Project.Name != "test-project" {
t.Errorf("config.Project.Name = %q, want test-project", config.Project.Name)
}
}
func TestInitAlreadyInitialized(t *testing.T) {
root := t.TempDir()
if err := Init(root, "test"); err != nil {
t.Fatalf("Init: %v", err)
}
err := Init(root, "test")
if err == nil {
t.Fatal("Init should fail when already initialized")
}
}
func TestIsInitialized(t *testing.T) {
root := t.TempDir()
if IsInitialized(root) {
t.Error("IsInitialized = true before init")
}
if err := Init(root, "test"); err != nil {
t.Fatalf("Init: %v", err)
}
if !IsInitialized(root) {
t.Error("IsInitialized = false after init")
}
}

99
internal/sdlc/paths.go Normal file
View File

@ -0,0 +1,99 @@
package sdlc
import (
"path/filepath"
"regexp"
)
const (
// SDLCDir is the root directory name for SDLC state.
SDLCDir = ".sdlc"
// StateFile is the filename for global state.
StateFile = "state.yaml"
// ConfigFile is the filename for project config.
ConfigFile = "config.yaml"
// FeaturesDir holds per-feature subdirectories.
FeaturesDir = "features"
// PatternsDir holds pattern definitions.
PatternsDir = "patterns"
// AuditsDir holds audit reports.
AuditsDir = "audits"
// BranchesDir holds branch tracking files.
BranchesDir = "branches"
// ArchivesDir holds archived (released) features.
ArchivesDir = "archives"
// RoadmapDir holds roadmap documents.
RoadmapDir = "roadmap"
// ManifestFile is the per-feature metadata file.
ManifestFile = "manifest.yaml"
)
var slugPattern = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
// ValidateSlug returns nil if the slug is valid, ErrInvalidSlug otherwise.
func ValidateSlug(slug string) error {
if slug == "" || len(slug) > 64 || !slugPattern.MatchString(slug) {
return ErrInvalidSlug
}
return nil
}
// SDLCRoot returns the .sdlc directory path within a project root.
func SDLCRoot(root string) string {
return filepath.Join(root, SDLCDir)
}
// StatePath returns the path to state.yaml.
func StatePath(root string) string {
return filepath.Join(root, SDLCDir, StateFile)
}
// ConfigPath returns the path to config.yaml.
func ConfigPath(root string) string {
return filepath.Join(root, SDLCDir, ConfigFile)
}
// FeaturesDirPath returns the features/ directory path.
func FeaturesDirPath(root string) string {
return filepath.Join(root, SDLCDir, FeaturesDir)
}
// FeatureDir returns the directory for a specific feature.
func FeatureDir(root, slug string) string {
return filepath.Join(root, SDLCDir, FeaturesDir, slug)
}
// ManifestPath returns the manifest.yaml path for a feature.
func ManifestPath(root, slug string) string {
return filepath.Join(root, SDLCDir, FeaturesDir, slug, ManifestFile)
}
// ArtifactPath returns the file path for a feature artifact.
func ArtifactPath(root, slug string, artifactType ArtifactType) string {
filename := ArtifactFilename(artifactType)
if filename == "" {
return ""
}
return filepath.Join(root, SDLCDir, FeaturesDir, slug, filename)
}
// SubDirs returns all subdirectories that sdlc init creates.
func SubDirs() []string {
return []string{
FeaturesDir,
PatternsDir,
AuditsDir,
BranchesDir,
ArchivesDir,
RoadmapDir,
}
}

114
internal/sdlc/paths_test.go Normal file
View File

@ -0,0 +1,114 @@
package sdlc
import (
"path/filepath"
"testing"
)
func TestStatePath(t *testing.T) {
got := StatePath("/project")
want := filepath.Join("/project", ".sdlc", "state.yaml")
if got != want {
t.Errorf("StatePath = %q, want %q", got, want)
}
}
func TestConfigPath(t *testing.T) {
got := ConfigPath("/project")
want := filepath.Join("/project", ".sdlc", "config.yaml")
if got != want {
t.Errorf("ConfigPath = %q, want %q", got, want)
}
}
func TestFeatureDir(t *testing.T) {
got := FeatureDir("/project", "auth")
want := filepath.Join("/project", ".sdlc", "features", "auth")
if got != want {
t.Errorf("FeatureDir = %q, want %q", got, want)
}
}
func TestManifestPath(t *testing.T) {
got := ManifestPath("/project", "auth")
want := filepath.Join("/project", ".sdlc", "features", "auth", "manifest.yaml")
if got != want {
t.Errorf("ManifestPath = %q, want %q", got, want)
}
}
func TestArtifactPath(t *testing.T) {
tests := []struct {
artifact ArtifactType
wantFile string
}{
{ArtifactSpec, "spec.md"},
{ArtifactDesign, "design.md"},
{ArtifactTasks, "tasks.md"},
{ArtifactQAPlan, "qa-plan.md"},
{ArtifactReview, "review.md"},
{ArtifactAudit, "audit.md"},
{ArtifactQAResults, "qa-results.md"},
}
for _, tt := range tests {
got := ArtifactPath("/project", "auth", tt.artifact)
want := filepath.Join("/project", ".sdlc", "features", "auth", tt.wantFile)
if got != want {
t.Errorf("ArtifactPath(%q) = %q, want %q", tt.artifact, got, want)
}
}
}
func TestArtifactPathInvalid(t *testing.T) {
got := ArtifactPath("/project", "auth", ArtifactType("bogus"))
if got != "" {
t.Errorf("ArtifactPath(bogus) = %q, want empty", got)
}
}
func TestValidateSlug(t *testing.T) {
valid := []string{"auth", "user-auth", "a1", "my-feature-2"}
for _, s := range valid {
if err := ValidateSlug(s); err != nil {
t.Errorf("ValidateSlug(%q) = %v, want nil", s, err)
}
}
invalid := []string{"", "Auth", "UPPER", "123start", "has spaces", "has_underscores", "-leading"}
for _, s := range invalid {
if err := ValidateSlug(s); err == nil {
t.Errorf("ValidateSlug(%q) = nil, want error", s)
}
}
}
func TestPhaseIndex(t *testing.T) {
if i := PhaseIndex(PhaseDraft); i != 0 {
t.Errorf("PhaseIndex(draft) = %d, want 0", i)
}
if i := PhaseIndex(PhaseReleased); i != 9 {
t.Errorf("PhaseIndex(released) = %d, want 9", i)
}
if i := PhaseIndex("bogus"); i != -1 {
t.Errorf("PhaseIndex(bogus) = %d, want -1", i)
}
}
func TestIsValidPhase(t *testing.T) {
if !IsValidPhase(PhaseDraft) {
t.Error("IsValidPhase(draft) = false, want true")
}
if IsValidPhase("bogus") {
t.Error("IsValidPhase(bogus) = true, want false")
}
}
func TestIsValidArtifactType(t *testing.T) {
if !IsValidArtifactType(ArtifactSpec) {
t.Error("IsValidArtifactType(spec) = false, want true")
}
if IsValidArtifactType("bogus") {
t.Error("IsValidArtifactType(bogus) = true, want false")
}
}

478
internal/sdlc/rules.go Normal file
View File

@ -0,0 +1,478 @@
package sdlc
import (
"fmt"
"strings"
)
// DefaultRules returns all classifier rules in priority order.
// First matching rule wins.
func DefaultRules() []Rule {
return []Rule{
blockedDependencyRule(),
needsSpecRule(),
specNeedsApprovalRule(),
specApprovedRule(),
needsDesignRule(),
designNeedsApprovalRule(),
needsTasksRule(),
tasksNeedApprovalRule(),
needsQAPlanRule(),
qaPlanNeedsApprovalRule(),
planningCompleteRule(),
readyToImplementRule(),
implementNextTaskRule(),
implementationCompleteRule(),
needsReviewRule(),
reviewHasIssuesRule(),
reviewPassedRule(),
needsAuditRule(),
auditHasIssuesRule(),
auditPassedRule(),
needsQARule(),
qaHasFailuresRule(),
qaPassedRule(),
needsMergeRule(),
archiveFeatureRule(),
}
}
func blockedDependencyRule() Rule {
return Rule{
ID: "blocked-dependency",
Condition: func(ctx *EvalContext) bool {
return ctx.Feature.IsBlocked()
},
Action: ActionBlocked,
Message: func(ctx *EvalContext) string {
return fmt.Sprintf("Feature blocked by: %s", strings.Join(ctx.Feature.Blockers, ", "))
},
}
}
func needsSpecRule() Rule {
return Rule{
ID: "needs-spec",
Condition: func(ctx *EvalContext) bool {
if ctx.Feature.Phase != PhaseDraft {
return false
}
art := ctx.Feature.GetArtifact(ArtifactSpec)
return art == nil || art.Status == StatusPending
},
Action: ActionCreateSpec,
NextCommand: func(ctx *EvalContext) string {
return "/spec-feature " + ctx.Feature.Slug
},
OutputPath: func(ctx *EvalContext) string {
return ".sdlc/features/" + ctx.Feature.Slug + "/spec.md"
},
}
}
func specNeedsApprovalRule() Rule {
return Rule{
ID: "spec-needs-approval",
Condition: func(ctx *EvalContext) bool {
if ctx.Feature.Phase != PhaseDraft {
return false
}
art := ctx.Feature.GetArtifact(ArtifactSpec)
return art != nil && art.Status == StatusDraft
},
Action: ActionAwaitApproval,
Message: func(_ *EvalContext) string {
return "Specification requires user approval"
},
}
}
func specApprovedRule() Rule {
return Rule{
ID: "spec-approved",
Condition: func(ctx *EvalContext) bool {
if ctx.Feature.Phase != PhaseDraft {
return false
}
art := ctx.Feature.GetArtifact(ArtifactSpec)
return art != nil && art.Status == StatusApproved
},
Action: ActionTransition,
TransitionTo: PhaseSpecified,
}
}
func needsDesignRule() Rule {
return Rule{
ID: "needs-design",
Condition: func(ctx *EvalContext) bool {
if ctx.Feature.Phase != PhaseSpecified {
return false
}
art := ctx.Feature.GetArtifact(ArtifactDesign)
return art == nil || art.Status == StatusPending
},
Action: ActionCreateDesign,
NextCommand: func(ctx *EvalContext) string {
return "/design-feature " + ctx.Feature.Slug
},
OutputPath: func(ctx *EvalContext) string {
return ".sdlc/features/" + ctx.Feature.Slug + "/design.md"
},
}
}
func designNeedsApprovalRule() Rule {
return Rule{
ID: "design-needs-approval",
Condition: func(ctx *EvalContext) bool {
if ctx.Feature.Phase != PhaseSpecified {
return false
}
art := ctx.Feature.GetArtifact(ArtifactDesign)
return art != nil && (art.Status == StatusDraft || art.Status == StatusRejected)
},
Action: ActionAwaitApproval,
Message: func(_ *EvalContext) string {
return "Design document requires user approval"
},
}
}
func needsTasksRule() Rule {
return Rule{
ID: "needs-tasks",
Condition: func(ctx *EvalContext) bool {
if ctx.Feature.Phase != PhaseSpecified {
return false
}
design := ctx.Feature.GetArtifact(ArtifactDesign)
if design == nil || design.Status != StatusApproved {
return false
}
tasks := ctx.Feature.GetArtifact(ArtifactTasks)
return tasks == nil || tasks.Status == StatusPending
},
Action: ActionCreateTasks,
NextCommand: func(ctx *EvalContext) string {
return "/breakdown-feature " + ctx.Feature.Slug
},
OutputPath: func(ctx *EvalContext) string {
return ".sdlc/features/" + ctx.Feature.Slug + "/tasks.md"
},
}
}
func tasksNeedApprovalRule() Rule {
return Rule{
ID: "tasks-need-approval",
Condition: func(ctx *EvalContext) bool {
if ctx.Feature.Phase != PhaseSpecified {
return false
}
tasks := ctx.Feature.GetArtifact(ArtifactTasks)
return tasks != nil && (tasks.Status == StatusDraft || tasks.Status == StatusRejected)
},
Action: ActionAwaitApproval,
Message: func(_ *EvalContext) string {
return "Task breakdown requires user approval"
},
}
}
func needsQAPlanRule() Rule {
return Rule{
ID: "needs-qa-plan",
Condition: func(ctx *EvalContext) bool {
if ctx.Feature.Phase != PhaseSpecified {
return false
}
tasks := ctx.Feature.GetArtifact(ArtifactTasks)
if tasks == nil || tasks.Status != StatusApproved {
return false
}
qa := ctx.Feature.GetArtifact(ArtifactQAPlan)
return qa == nil || qa.Status == StatusPending
},
Action: ActionCreateQAPlan,
NextCommand: func(ctx *EvalContext) string {
return "/create-qa-plan " + ctx.Feature.Slug
},
OutputPath: func(ctx *EvalContext) string {
return ".sdlc/features/" + ctx.Feature.Slug + "/qa-plan.md"
},
}
}
func qaPlanNeedsApprovalRule() Rule {
return Rule{
ID: "qa-plan-needs-approval",
Condition: func(ctx *EvalContext) bool {
if ctx.Feature.Phase != PhaseSpecified {
return false
}
qa := ctx.Feature.GetArtifact(ArtifactQAPlan)
return qa != nil && (qa.Status == StatusDraft || qa.Status == StatusRejected)
},
Action: ActionAwaitApproval,
Message: func(_ *EvalContext) string {
return "QA plan requires user approval"
},
}
}
func planningCompleteRule() Rule {
return Rule{
ID: "planning-complete",
Condition: func(ctx *EvalContext) bool {
if ctx.Feature.Phase != PhaseSpecified {
return false
}
qa := ctx.Feature.GetArtifact(ArtifactQAPlan)
return qa != nil && qa.Status == StatusApproved
},
Action: ActionTransition,
TransitionTo: PhasePlanned,
}
}
func readyToImplementRule() Rule {
return Rule{
ID: "ready-to-implement",
Condition: func(ctx *EvalContext) bool {
return ctx.Feature.Phase == PhasePlanned || ctx.Feature.Phase == PhaseReady
},
Action: ActionTransition,
TransitionTo: PhaseReady,
Message: func(ctx *EvalContext) string {
if ctx.Feature.Phase == PhasePlanned {
return "Ready for implementation: transition to ready, then implementation"
}
return "Ready for implementation"
},
}
}
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
},
}
}

121
internal/sdlc/state.go Normal file
View File

@ -0,0 +1,121 @@
package sdlc
import (
"fmt"
"os"
"time"
"gopkg.in/yaml.v3"
)
// State represents the global SDLC state stored in .sdlc/state.yaml.
type State struct {
Version int `yaml:"version" json:"version"`
Project ProjectState `yaml:"project" json:"project"`
ActiveWork ActiveWork `yaml:"active_work" json:"active_work"`
Blocked []BlockedItem `yaml:"blocked" json:"blocked"`
LastUpdated *time.Time `yaml:"last_updated,omitempty" json:"last_updated,omitempty"`
LastAction string `yaml:"last_action,omitempty" json:"last_action,omitempty"`
LastActor string `yaml:"last_actor,omitempty" json:"last_actor,omitempty"`
History []HistoryEntry `yaml:"history,omitempty" json:"history,omitempty"`
}
// LoadState reads and parses .sdlc/state.yaml from the given project root.
func LoadState(root string) (*State, error) {
path := StatePath(root)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, ErrNotInitialized
}
return nil, fmt.Errorf("read state file: %w", err)
}
var s State
if err := yaml.Unmarshal(data, &s); err != nil {
return nil, fmt.Errorf("parse state file: %w", err)
}
return &s, nil
}
// Save writes the state to .sdlc/state.yaml.
func (s *State) Save(root string) error {
now := time.Now().UTC()
s.LastUpdated = &now
data, err := yaml.Marshal(s)
if err != nil {
return fmt.Errorf("marshal state: %w", err)
}
path := StatePath(root)
if err := os.WriteFile(path, data, 0o644); err != nil {
return fmt.Errorf("write state file: %w", err)
}
return nil
}
// RecordAction appends a history entry and updates the last-action fields.
func (s *State) RecordAction(action, feature, actor string) {
entry := HistoryEntry{
Timestamp: time.Now().UTC(),
Action: action,
Feature: feature,
Actor: actor,
Result: "success",
}
s.History = append(s.History, entry)
s.LastAction = action
s.LastActor = actor
}
// AddActiveFeature adds a feature to active work if not already present.
func (s *State) AddActiveFeature(slug string, phase FeaturePhase) {
for _, f := range s.ActiveWork.Features {
if f.Slug == slug {
return
}
}
s.ActiveWork.Features = append(s.ActiveWork.Features, ActiveFeature{
Slug: slug,
Phase: phase,
})
}
// UpdateActiveFeature updates the phase of an active feature.
func (s *State) UpdateActiveFeature(slug string, phase FeaturePhase, branch string) {
for i, f := range s.ActiveWork.Features {
if f.Slug == slug {
s.ActiveWork.Features[i].Phase = phase
if branch != "" {
s.ActiveWork.Features[i].Branch = branch
}
return
}
}
}
// RemoveActiveFeature removes a feature from active work.
func (s *State) RemoveActiveFeature(slug string) {
for i, f := range s.ActiveWork.Features {
if f.Slug == slug {
s.ActiveWork.Features = append(s.ActiveWork.Features[:i], s.ActiveWork.Features[i+1:]...)
return
}
}
}
// DefaultState returns a new State with version 1 and empty fields.
func DefaultState(projectName string) *State {
return &State{
Version: 1,
Project: ProjectState{
Name: projectName,
},
ActiveWork: ActiveWork{
Features: []ActiveFeature{},
},
Blocked: []BlockedItem{},
History: []HistoryEntry{},
}
}

111
internal/sdlc/state_test.go Normal file
View File

@ -0,0 +1,111 @@
package sdlc
import (
"os"
"testing"
)
func TestStateRoundTrip(t *testing.T) {
root := t.TempDir()
// Create .sdlc directory
if err := os.MkdirAll(SDLCRoot(root), 0o755); err != nil {
t.Fatal(err)
}
original := DefaultState("test-project")
original.RecordAction("test-action", "auth", "tester")
original.AddActiveFeature("auth", PhaseDraft)
if err := original.Save(root); err != nil {
t.Fatalf("Save: %v", err)
}
loaded, err := LoadState(root)
if err != nil {
t.Fatalf("LoadState: %v", err)
}
if loaded.Version != 1 {
t.Errorf("Version = %d, want 1", loaded.Version)
}
if loaded.Project.Name != "test-project" {
t.Errorf("Project.Name = %q, want %q", loaded.Project.Name, "test-project")
}
if len(loaded.History) != 1 {
t.Fatalf("History len = %d, want 1", len(loaded.History))
}
if loaded.History[0].Action != "test-action" {
t.Errorf("History[0].Action = %q, want %q", loaded.History[0].Action, "test-action")
}
if loaded.LastAction != "test-action" {
t.Errorf("LastAction = %q, want %q", loaded.LastAction, "test-action")
}
if loaded.LastActor != "tester" {
t.Errorf("LastActor = %q, want %q", loaded.LastActor, "tester")
}
if len(loaded.ActiveWork.Features) != 1 {
t.Fatalf("ActiveWork.Features len = %d, want 1", len(loaded.ActiveWork.Features))
}
if loaded.ActiveWork.Features[0].Slug != "auth" {
t.Errorf("ActiveWork.Features[0].Slug = %q, want %q", loaded.ActiveWork.Features[0].Slug, "auth")
}
}
func TestLoadStateNotInitialized(t *testing.T) {
root := t.TempDir()
_, err := LoadState(root)
if err != ErrNotInitialized {
t.Errorf("LoadState = %v, want ErrNotInitialized", err)
}
}
func TestRecordAction(t *testing.T) {
s := DefaultState("test")
s.RecordAction("CREATE_SPEC", "auth", "claude")
s.RecordAction("TRANSITION", "auth", "classifier")
if len(s.History) != 2 {
t.Fatalf("History len = %d, want 2", len(s.History))
}
if s.LastAction != "TRANSITION" {
t.Errorf("LastAction = %q, want TRANSITION", s.LastAction)
}
}
func TestAddActiveFeatureDeduplicate(t *testing.T) {
s := DefaultState("test")
s.AddActiveFeature("auth", PhaseDraft)
s.AddActiveFeature("auth", PhaseDraft) // duplicate
if len(s.ActiveWork.Features) != 1 {
t.Errorf("Features len = %d, want 1", len(s.ActiveWork.Features))
}
}
func TestUpdateActiveFeature(t *testing.T) {
s := DefaultState("test")
s.AddActiveFeature("auth", PhaseDraft)
s.UpdateActiveFeature("auth", PhaseSpecified, "feature/auth")
if s.ActiveWork.Features[0].Phase != PhaseSpecified {
t.Errorf("Phase = %q, want specified", s.ActiveWork.Features[0].Phase)
}
if s.ActiveWork.Features[0].Branch != "feature/auth" {
t.Errorf("Branch = %q, want feature/auth", s.ActiveWork.Features[0].Branch)
}
}
func TestRemoveActiveFeature(t *testing.T) {
s := DefaultState("test")
s.AddActiveFeature("auth", PhaseDraft)
s.AddActiveFeature("payments", PhaseDraft)
s.RemoveActiveFeature("auth")
if len(s.ActiveWork.Features) != 1 {
t.Fatalf("Features len = %d, want 1", len(s.ActiveWork.Features))
}
if s.ActiveWork.Features[0].Slug != "payments" {
t.Errorf("Features[0].Slug = %q, want payments", s.ActiveWork.Features[0].Slug)
}
}

134
internal/sdlc/task.go Normal file
View File

@ -0,0 +1,134 @@
package sdlc
import (
"fmt"
"time"
)
// Task represents an implementation task within a feature.
type Task struct {
ID string `yaml:"id" json:"id"`
Title string `yaml:"title" json:"title"`
Status TaskStatus `yaml:"status" json:"status"`
Spec string `yaml:"spec,omitempty" json:"spec,omitempty"`
Files []string `yaml:"files,omitempty" json:"files,omitempty"`
Patterns []string `yaml:"patterns,omitempty" json:"patterns,omitempty"`
DependsOn []string `yaml:"depends_on,omitempty" json:"depends_on,omitempty"`
StartedAt *time.Time `yaml:"started_at,omitempty" json:"started_at,omitempty"`
DoneAt *time.Time `yaml:"done_at,omitempty" json:"done_at,omitempty"`
Notes string `yaml:"notes,omitempty" json:"notes,omitempty"`
}
// StartTask marks a task as in-progress.
func StartTask(tasks []Task, taskID string) ([]Task, error) {
for i, t := range tasks {
if t.ID == taskID {
if t.Status != TaskPending && t.Status != TaskBlocked {
return tasks, fmt.Errorf("task %s is %s, not startable", taskID, t.Status)
}
now := time.Now().UTC()
tasks[i].Status = TaskInProgress
tasks[i].StartedAt = &now
return tasks, nil
}
}
return tasks, ErrTaskNotFound
}
// CompleteTask marks a task as complete.
func CompleteTask(tasks []Task, taskID string) ([]Task, error) {
for i, t := range tasks {
if t.ID == taskID {
if t.Status != TaskInProgress {
return tasks, fmt.Errorf("task %s is %s, not completable", taskID, t.Status)
}
now := time.Now().UTC()
tasks[i].Status = TaskComplete
tasks[i].DoneAt = &now
return tasks, nil
}
}
return tasks, ErrTaskNotFound
}
// BlockTask marks a task as blocked.
func BlockTask(tasks []Task, taskID string) ([]Task, error) {
for i, t := range tasks {
if t.ID == taskID {
tasks[i].Status = TaskBlocked
return tasks, nil
}
}
return tasks, ErrTaskNotFound
}
// AddTask appends a new task with an auto-generated ID.
func AddTask(tasks []Task, title string) []Task {
id := fmt.Sprintf("task-%03d", len(tasks)+1)
return append(tasks, Task{
ID: id,
Title: title,
Status: TaskPending,
})
}
// PendingTasks returns tasks that are pending or blocked.
func PendingTasks(tasks []Task) []Task {
var result []Task
for _, t := range tasks {
if t.Status == TaskPending {
result = append(result, t)
}
}
return result
}
// NextTask returns the first pending task, or nil if none.
func NextTask(tasks []Task) *Task {
for i, t := range tasks {
if t.Status == TaskPending {
return &tasks[i]
}
}
return nil
}
// AllTasksComplete returns true if every task is in the complete state.
func AllTasksComplete(tasks []Task) bool {
if len(tasks) == 0 {
return false
}
for _, t := range tasks {
if t.Status != TaskComplete {
return false
}
}
return true
}
// TaskSummary returns counts by status.
type TaskSummary struct {
Total int `json:"total"`
Completed int `json:"completed"`
InProgress int `json:"in_progress"`
Pending int `json:"pending"`
Blocked int `json:"blocked"`
}
// SummarizeTasks computes a TaskSummary from a task list.
func SummarizeTasks(tasks []Task) TaskSummary {
s := TaskSummary{Total: len(tasks)}
for _, t := range tasks {
switch t.Status {
case TaskComplete:
s.Completed++
case TaskInProgress:
s.InProgress++
case TaskPending:
s.Pending++
case TaskBlocked:
s.Blocked++
}
}
return s
}

163
internal/sdlc/task_test.go Normal file
View File

@ -0,0 +1,163 @@
package sdlc
import "testing"
func TestAddTask(t *testing.T) {
tasks := AddTask(nil, "Create user model")
tasks = AddTask(tasks, "Add validation")
if len(tasks) != 2 {
t.Fatalf("len = %d, want 2", len(tasks))
}
if tasks[0].ID != "task-001" {
t.Errorf("tasks[0].ID = %q, want task-001", tasks[0].ID)
}
if tasks[1].ID != "task-002" {
t.Errorf("tasks[1].ID = %q, want task-002", tasks[1].ID)
}
if tasks[0].Status != TaskPending {
t.Errorf("tasks[0].Status = %q, want pending", tasks[0].Status)
}
}
func TestStartTask(t *testing.T) {
tasks := AddTask(nil, "Task 1")
tasks, err := StartTask(tasks, "task-001")
if err != nil {
t.Fatalf("StartTask: %v", err)
}
if tasks[0].Status != TaskInProgress {
t.Errorf("Status = %q, want in_progress", tasks[0].Status)
}
if tasks[0].StartedAt == nil {
t.Error("StartedAt is nil")
}
}
func TestStartTaskNotFound(t *testing.T) {
tasks := AddTask(nil, "Task 1")
_, err := StartTask(tasks, "task-999")
if err != ErrTaskNotFound {
t.Errorf("err = %v, want ErrTaskNotFound", err)
}
}
func TestStartTaskWrongStatus(t *testing.T) {
tasks := AddTask(nil, "Task 1")
tasks, _ = StartTask(tasks, "task-001")
tasks, _ = CompleteTask(tasks, "task-001")
_, err := StartTask(tasks, "task-001")
if err == nil {
t.Error("StartTask on complete task should fail")
}
}
func TestCompleteTask(t *testing.T) {
tasks := AddTask(nil, "Task 1")
tasks, _ = StartTask(tasks, "task-001")
tasks, err := CompleteTask(tasks, "task-001")
if err != nil {
t.Fatalf("CompleteTask: %v", err)
}
if tasks[0].Status != TaskComplete {
t.Errorf("Status = %q, want complete", tasks[0].Status)
}
if tasks[0].DoneAt == nil {
t.Error("DoneAt is nil")
}
}
func TestCompleteTaskWrongStatus(t *testing.T) {
tasks := AddTask(nil, "Task 1")
_, err := CompleteTask(tasks, "task-001")
if err == nil {
t.Error("CompleteTask on pending task should fail")
}
}
func TestBlockTask(t *testing.T) {
tasks := AddTask(nil, "Task 1")
tasks, err := BlockTask(tasks, "task-001")
if err != nil {
t.Fatalf("BlockTask: %v", err)
}
if tasks[0].Status != TaskBlocked {
t.Errorf("Status = %q, want blocked", tasks[0].Status)
}
}
func TestPendingTasks(t *testing.T) {
tasks := AddTask(nil, "Task 1")
tasks = AddTask(tasks, "Task 2")
tasks = AddTask(tasks, "Task 3")
tasks, _ = StartTask(tasks, "task-001")
tasks, _ = CompleteTask(tasks, "task-001")
pending := PendingTasks(tasks)
if len(pending) != 2 {
t.Errorf("PendingTasks len = %d, want 2", len(pending))
}
}
func TestNextTask(t *testing.T) {
tasks := AddTask(nil, "Task 1")
tasks = AddTask(tasks, "Task 2")
tasks, _ = StartTask(tasks, "task-001")
tasks, _ = CompleteTask(tasks, "task-001")
next := NextTask(tasks)
if next == nil {
t.Fatal("NextTask = nil, want task-002")
}
if next.ID != "task-002" {
t.Errorf("NextTask.ID = %q, want task-002", next.ID)
}
}
func TestAllTasksComplete(t *testing.T) {
if AllTasksComplete(nil) {
t.Error("AllTasksComplete(nil) = true, want false")
}
tasks := AddTask(nil, "Task 1")
tasks = AddTask(tasks, "Task 2")
if AllTasksComplete(tasks) {
t.Error("AllTasksComplete = true with pending tasks")
}
tasks, _ = StartTask(tasks, "task-001")
tasks, _ = CompleteTask(tasks, "task-001")
tasks, _ = StartTask(tasks, "task-002")
tasks, _ = CompleteTask(tasks, "task-002")
if !AllTasksComplete(tasks) {
t.Error("AllTasksComplete = false with all complete")
}
}
func TestSummarizeTasks(t *testing.T) {
tasks := AddTask(nil, "Task 1")
tasks = AddTask(tasks, "Task 2")
tasks = AddTask(tasks, "Task 3")
tasks, _ = StartTask(tasks, "task-001")
tasks, _ = CompleteTask(tasks, "task-001")
tasks, _ = StartTask(tasks, "task-002")
s := SummarizeTasks(tasks)
if s.Total != 3 {
t.Errorf("Total = %d, want 3", s.Total)
}
if s.Completed != 1 {
t.Errorf("Completed = %d, want 1", s.Completed)
}
if s.InProgress != 1 {
t.Errorf("InProgress = %d, want 1", s.InProgress)
}
if s.Pending != 1 {
t.Errorf("Pending = %d, want 1", s.Pending)
}
}

162
internal/sdlc/types.go Normal file
View File

@ -0,0 +1,162 @@
package sdlc
import (
"slices"
"time"
)
// FeaturePhase represents a stage in the feature lifecycle.
type FeaturePhase string
const (
PhaseDraft FeaturePhase = "draft"
PhaseSpecified FeaturePhase = "specified"
PhasePlanned FeaturePhase = "planned"
PhaseReady FeaturePhase = "ready"
PhaseImplementation FeaturePhase = "implementation"
PhaseReview FeaturePhase = "review"
PhaseAudit FeaturePhase = "audit"
PhaseQA FeaturePhase = "qa"
PhaseMerge FeaturePhase = "merge"
PhaseReleased FeaturePhase = "released"
)
// ValidPhases is the ordered list of all feature phases.
var ValidPhases = []FeaturePhase{
PhaseDraft,
PhaseSpecified,
PhasePlanned,
PhaseReady,
PhaseImplementation,
PhaseReview,
PhaseAudit,
PhaseQA,
PhaseMerge,
PhaseReleased,
}
// PhaseIndex returns the ordinal position of a phase, or -1 if invalid.
func PhaseIndex(p FeaturePhase) int {
for i, v := range ValidPhases {
if v == p {
return i
}
}
return -1
}
// IsValidPhase returns true if the phase is recognized.
func IsValidPhase(p FeaturePhase) bool {
return PhaseIndex(p) >= 0
}
// ArtifactType identifies the kind of artifact produced during a phase.
type ArtifactType string
const (
ArtifactSpec ArtifactType = "spec"
ArtifactDesign ArtifactType = "design"
ArtifactTasks ArtifactType = "tasks"
ArtifactQAPlan ArtifactType = "qa_plan"
ArtifactReview ArtifactType = "review"
ArtifactAudit ArtifactType = "audit"
ArtifactQAResults ArtifactType = "qa_results"
)
// ValidArtifactTypes lists all recognized artifact types.
var ValidArtifactTypes = []ArtifactType{
ArtifactSpec,
ArtifactDesign,
ArtifactTasks,
ArtifactQAPlan,
ArtifactReview,
ArtifactAudit,
ArtifactQAResults,
}
// IsValidArtifactType returns true if the artifact type is recognized.
func IsValidArtifactType(t ArtifactType) bool {
return slices.Contains(ValidArtifactTypes, t)
}
// ArtifactFilename returns the file name for an artifact type.
func ArtifactFilename(t ArtifactType) string {
switch t {
case ArtifactSpec:
return "spec.md"
case ArtifactDesign:
return "design.md"
case ArtifactTasks:
return "tasks.md"
case ArtifactQAPlan:
return "qa-plan.md"
case ArtifactReview:
return "review.md"
case ArtifactAudit:
return "audit.md"
case ArtifactQAResults:
return "qa-results.md"
default:
return ""
}
}
// ArtifactStatus tracks the approval state of an artifact.
type ArtifactStatus string
const (
StatusPending ArtifactStatus = "pending"
StatusDraft ArtifactStatus = "draft"
StatusApproved ArtifactStatus = "approved"
StatusRejected ArtifactStatus = "rejected"
StatusNeedsFix ArtifactStatus = "needs_fix"
StatusPassed ArtifactStatus = "passed"
StatusFailed ArtifactStatus = "failed"
)
// ActionType identifies what the classifier recommends.
type ActionType string
const (
ActionBlocked ActionType = "BLOCKED"
ActionAwaitApproval ActionType = "AWAIT_APPROVAL"
ActionTransition ActionType = "TRANSITION"
ActionCreateSpec ActionType = "CREATE_SPEC"
ActionCreateDesign ActionType = "CREATE_DESIGN"
ActionCreateTasks ActionType = "CREATE_TASKS"
ActionCreateQAPlan ActionType = "CREATE_QA_PLAN"
ActionCreateBranch ActionType = "CREATE_BRANCH"
ActionImplementTask ActionType = "IMPLEMENT_TASK"
ActionReviewCode ActionType = "REVIEW_CODE"
ActionFixReviewIssues ActionType = "FIX_REVIEW_ISSUES"
ActionAuditCode ActionType = "AUDIT_CODE"
ActionRemediateAudit ActionType = "REMEDIATE_AUDIT"
ActionRunQA ActionType = "RUN_QA"
ActionFixQAFailures ActionType = "FIX_QA_FAILURES"
ActionMergeFeature ActionType = "MERGE_FEATURE"
ActionArchive ActionType = "ARCHIVE"
ActionIdle ActionType = "IDLE"
)
// TaskStatus tracks the state of an implementation task.
type TaskStatus string
const (
TaskPending TaskStatus = "pending"
TaskInProgress TaskStatus = "in_progress"
TaskComplete TaskStatus = "complete"
TaskBlocked TaskStatus = "blocked"
)
// PhaseTransition records when a feature moved between phases.
type PhaseTransition struct {
Phase FeaturePhase `yaml:"phase" json:"phase"`
Entered time.Time `yaml:"entered" json:"entered"`
Exited *time.Time `yaml:"exited,omitempty" json:"exited,omitempty"`
}
// Dependencies tracks what a feature depends on.
type Dependencies struct {
Features []string `yaml:"features,omitempty" json:"features,omitempty"`
Patterns []string `yaml:"patterns,omitempty" json:"patterns,omitempty"`
}

View File

@ -0,0 +1,259 @@
package service
import (
"context"
"fmt"
"log/slog"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
"github.com/orchard9/rdev/internal/sdlc"
)
// SDLCService provides SDLC operations for projects.
// It resolves project IDs to pod names and delegates to the SDLCExecutor.
type SDLCService struct {
sdlcExec port.SDLCExecutor
projectRepo port.ProjectRepository
logger *slog.Logger
}
// SDLCServiceConfig configures the SDLC service.
type SDLCServiceConfig struct {
Logger *slog.Logger
}
// NewSDLCService creates a new SDLC service.
func NewSDLCService(sdlcExec port.SDLCExecutor, projectRepo port.ProjectRepository, cfg SDLCServiceConfig) *SDLCService {
logger := cfg.Logger
if logger == nil {
logger = slog.Default()
}
return &SDLCService{
sdlcExec: sdlcExec,
projectRepo: projectRepo,
logger: logger.With("component", "sdlc-service"),
}
}
// resolveProjectPod looks up a project and returns its pod name.
func (s *SDLCService) resolveProjectPod(ctx context.Context, projectID string) (string, error) {
project, err := s.projectRepo.Get(ctx, domain.ProjectID(projectID))
if err != nil {
return "", domain.ErrProjectNotFound
}
if project.PodName == "" {
return "", fmt.Errorf("project %s has no pod", projectID)
}
return project.PodName, nil
}
// GetState returns the global SDLC state for a project.
func (s *SDLCService) GetState(ctx context.Context, projectID string) (*sdlc.State, error) {
podName, err := s.resolveProjectPod(ctx, projectID)
if err != nil {
return nil, err
}
return s.sdlcExec.GetState(ctx, podName)
}
// GetNext returns the classifier's recommendation for the next action.
func (s *SDLCService) GetNext(ctx context.Context, projectID, feature string) (*sdlc.Classification, error) {
podName, err := s.resolveProjectPod(ctx, projectID)
if err != nil {
return nil, err
}
return s.sdlcExec.GetNext(ctx, podName, feature)
}
// ListFeatures returns all features in a project.
func (s *SDLCService) ListFeatures(ctx context.Context, projectID string) ([]*sdlc.Feature, error) {
podName, err := s.resolveProjectPod(ctx, projectID)
if err != nil {
return nil, err
}
return s.sdlcExec.ListFeatures(ctx, podName)
}
// GetFeature returns a single feature by slug.
func (s *SDLCService) GetFeature(ctx context.Context, projectID, slug string) (*sdlc.Feature, error) {
podName, err := s.resolveProjectPod(ctx, projectID)
if err != nil {
return nil, err
}
return s.sdlcExec.GetFeature(ctx, podName, slug)
}
// CreateFeature creates a new feature.
func (s *SDLCService) CreateFeature(ctx context.Context, projectID, slug, title string) (*sdlc.Feature, error) {
podName, err := s.resolveProjectPod(ctx, projectID)
if err != nil {
return nil, err
}
f, err := s.sdlcExec.CreateFeature(ctx, podName, slug, title)
if err != nil {
return nil, err
}
s.logger.Info("feature created", "project", projectID, "feature", slug)
return f, nil
}
// TransitionFeature moves a feature to the specified phase.
func (s *SDLCService) TransitionFeature(ctx context.Context, projectID, slug string, phase sdlc.FeaturePhase) error {
podName, err := s.resolveProjectPod(ctx, projectID)
if err != nil {
return err
}
if err := s.sdlcExec.TransitionFeature(ctx, podName, slug, phase); err != nil {
s.logger.Error("transition feature failed", "project", projectID, "feature", slug, "phase", string(phase), "error", err)
return err
}
s.logger.Info("feature transitioned", "project", projectID, "feature", slug, "phase", string(phase))
return nil
}
// BlockFeature adds a blocker reason to a feature.
func (s *SDLCService) BlockFeature(ctx context.Context, projectID, slug, reason string) error {
podName, err := s.resolveProjectPod(ctx, projectID)
if err != nil {
return err
}
if err := s.sdlcExec.BlockFeature(ctx, podName, slug, reason); err != nil {
return err
}
s.logger.Info("feature blocked", "project", projectID, "feature", slug, "reason", reason)
return nil
}
// UnblockFeature removes all blockers from a feature.
func (s *SDLCService) UnblockFeature(ctx context.Context, projectID, slug string) error {
podName, err := s.resolveProjectPod(ctx, projectID)
if err != nil {
return err
}
if err := s.sdlcExec.UnblockFeature(ctx, podName, slug); err != nil {
return err
}
s.logger.Info("feature unblocked", "project", projectID, "feature", slug)
return nil
}
// DeleteFeature removes a feature entirely.
func (s *SDLCService) DeleteFeature(ctx context.Context, projectID, slug string) error {
podName, err := s.resolveProjectPod(ctx, projectID)
if err != nil {
return err
}
if err := s.sdlcExec.DeleteFeature(ctx, podName, slug); err != nil {
return err
}
s.logger.Info("feature deleted", "project", projectID, "feature", slug)
return nil
}
// GetArtifactStatus returns artifact statuses for a feature.
func (s *SDLCService) GetArtifactStatus(ctx context.Context, projectID, slug string) (map[sdlc.ArtifactType]*sdlc.Artifact, error) {
podName, err := s.resolveProjectPod(ctx, projectID)
if err != nil {
return nil, err
}
return s.sdlcExec.GetArtifactStatus(ctx, podName, slug)
}
// ApproveArtifact approves a feature artifact.
func (s *SDLCService) ApproveArtifact(ctx context.Context, projectID, slug string, artType sdlc.ArtifactType) error {
podName, err := s.resolveProjectPod(ctx, projectID)
if err != nil {
return err
}
if err := s.sdlcExec.ApproveArtifact(ctx, podName, slug, artType); err != nil {
return err
}
s.logger.Info("artifact approved", "project", projectID, "feature", slug, "artifact", string(artType))
return nil
}
// RejectArtifact rejects a feature artifact.
func (s *SDLCService) RejectArtifact(ctx context.Context, projectID, slug string, artType sdlc.ArtifactType) error {
podName, err := s.resolveProjectPod(ctx, projectID)
if err != nil {
return err
}
if err := s.sdlcExec.RejectArtifact(ctx, podName, slug, artType); err != nil {
return err
}
s.logger.Info("artifact rejected", "project", projectID, "feature", slug, "artifact", string(artType))
return nil
}
// ListTasks returns all tasks for a feature.
func (s *SDLCService) ListTasks(ctx context.Context, projectID, slug string) ([]sdlc.Task, error) {
podName, err := s.resolveProjectPod(ctx, projectID)
if err != nil {
return nil, err
}
return s.sdlcExec.ListTasks(ctx, podName, slug)
}
// AddTask adds a new task to a feature.
func (s *SDLCService) AddTask(ctx context.Context, projectID, slug, title string) (*sdlc.Task, error) {
podName, err := s.resolveProjectPod(ctx, projectID)
if err != nil {
return nil, err
}
return s.sdlcExec.AddTask(ctx, podName, slug, title)
}
// StartTask marks a task as in-progress.
func (s *SDLCService) StartTask(ctx context.Context, projectID, slug, taskID string) error {
podName, err := s.resolveProjectPod(ctx, projectID)
if err != nil {
return err
}
return s.sdlcExec.StartTask(ctx, podName, slug, taskID)
}
// CompleteTask marks a task as complete.
func (s *SDLCService) CompleteTask(ctx context.Context, projectID, slug, taskID string) error {
podName, err := s.resolveProjectPod(ctx, projectID)
if err != nil {
return err
}
return s.sdlcExec.CompleteTask(ctx, podName, slug, taskID)
}
// BlockTask marks a task as blocked.
func (s *SDLCService) BlockTask(ctx context.Context, projectID, slug, taskID string) error {
podName, err := s.resolveProjectPod(ctx, projectID)
if err != nil {
return err
}
return s.sdlcExec.BlockTask(ctx, podName, slug, taskID)
}
// QueryBlocked returns all blocked features in a project.
func (s *SDLCService) QueryBlocked(ctx context.Context, projectID string) ([]port.BlockedInfo, error) {
podName, err := s.resolveProjectPod(ctx, projectID)
if err != nil {
return nil, err
}
return s.sdlcExec.QueryBlocked(ctx, podName)
}
// QueryReady returns features ready for work in a project.
func (s *SDLCService) QueryReady(ctx context.Context, projectID string) ([]port.ReadyInfo, error) {
podName, err := s.resolveProjectPod(ctx, projectID)
if err != nil {
return nil, err
}
return s.sdlcExec.QueryReady(ctx, podName)
}
// QueryNeedsApproval returns features awaiting approval in a project.
func (s *SDLCService) QueryNeedsApproval(ctx context.Context, projectID string) ([]port.ApprovalInfo, error) {
podName, err := s.resolveProjectPod(ctx, projectID)
if err != nil {
return nil, err
}
return s.sdlcExec.QueryNeedsApproval(ctx, podName)
}

View File

@ -0,0 +1,374 @@
package service
import (
"context"
"errors"
"testing"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
"github.com/orchard9/rdev/internal/sdlc"
)
// mockSDLCExecutor implements port.SDLCExecutor for testing.
type mockSDLCExecutor struct {
getStateFn func(ctx context.Context, podName string) (*sdlc.State, error)
getNextFn func(ctx context.Context, podName, feature string) (*sdlc.Classification, error)
listFeaturesFn func(ctx context.Context, podName string) ([]*sdlc.Feature, error)
getFeatureFn func(ctx context.Context, podName, slug string) (*sdlc.Feature, error)
createFeatureFn func(ctx context.Context, podName, slug, title string) (*sdlc.Feature, error)
transitionFeatureFn func(ctx context.Context, podName, slug string, phase sdlc.FeaturePhase) error
blockFeatureFn func(ctx context.Context, podName, slug, reason string) error
unblockFeatureFn func(ctx context.Context, podName, slug string) error
deleteFeatureFn func(ctx context.Context, podName, slug string) error
getArtifactStatusFn func(ctx context.Context, podName, slug string) (map[sdlc.ArtifactType]*sdlc.Artifact, error)
approveArtifactFn func(ctx context.Context, podName, slug string, artType sdlc.ArtifactType) error
rejectArtifactFn func(ctx context.Context, podName, slug string, artType sdlc.ArtifactType) error
listTasksFn func(ctx context.Context, podName, slug string) ([]sdlc.Task, error)
addTaskFn func(ctx context.Context, podName, slug, title string) (*sdlc.Task, error)
startTaskFn func(ctx context.Context, podName, slug, taskID string) error
completeTaskFn func(ctx context.Context, podName, slug, taskID string) error
blockTaskFn func(ctx context.Context, podName, slug, taskID string) error
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)
}
func (m *mockSDLCExecutor) GetState(ctx context.Context, podName string) (*sdlc.State, error) {
if m.getStateFn != nil {
return m.getStateFn(ctx, podName)
}
return &sdlc.State{Version: 1}, nil
}
func (m *mockSDLCExecutor) GetNext(ctx context.Context, podName, feature string) (*sdlc.Classification, error) {
if m.getNextFn != nil {
return m.getNextFn(ctx, podName, feature)
}
return &sdlc.Classification{Action: sdlc.ActionIdle}, nil
}
func (m *mockSDLCExecutor) ListFeatures(ctx context.Context, podName string) ([]*sdlc.Feature, error) {
if m.listFeaturesFn != nil {
return m.listFeaturesFn(ctx, podName)
}
return nil, nil
}
func (m *mockSDLCExecutor) GetFeature(ctx context.Context, podName, slug string) (*sdlc.Feature, error) {
if m.getFeatureFn != nil {
return m.getFeatureFn(ctx, podName, slug)
}
return &sdlc.Feature{Slug: slug}, nil
}
func (m *mockSDLCExecutor) CreateFeature(ctx context.Context, podName, slug, title string) (*sdlc.Feature, error) {
if m.createFeatureFn != nil {
return m.createFeatureFn(ctx, podName, slug, title)
}
return &sdlc.Feature{Slug: slug, Title: title}, nil
}
func (m *mockSDLCExecutor) TransitionFeature(ctx context.Context, podName, slug string, phase sdlc.FeaturePhase) error {
if m.transitionFeatureFn != nil {
return m.transitionFeatureFn(ctx, podName, slug, phase)
}
return nil
}
func (m *mockSDLCExecutor) BlockFeature(ctx context.Context, podName, slug, reason string) error {
if m.blockFeatureFn != nil {
return m.blockFeatureFn(ctx, podName, slug, reason)
}
return nil
}
func (m *mockSDLCExecutor) UnblockFeature(ctx context.Context, podName, slug string) error {
if m.unblockFeatureFn != nil {
return m.unblockFeatureFn(ctx, podName, slug)
}
return nil
}
func (m *mockSDLCExecutor) DeleteFeature(ctx context.Context, podName, slug string) error {
if m.deleteFeatureFn != nil {
return m.deleteFeatureFn(ctx, podName, slug)
}
return nil
}
func (m *mockSDLCExecutor) GetArtifactStatus(ctx context.Context, podName, slug string) (map[sdlc.ArtifactType]*sdlc.Artifact, error) {
if m.getArtifactStatusFn != nil {
return m.getArtifactStatusFn(ctx, podName, slug)
}
return nil, nil
}
func (m *mockSDLCExecutor) ApproveArtifact(ctx context.Context, podName, slug string, artType sdlc.ArtifactType) error {
if m.approveArtifactFn != nil {
return m.approveArtifactFn(ctx, podName, slug, artType)
}
return nil
}
func (m *mockSDLCExecutor) RejectArtifact(ctx context.Context, podName, slug string, artType sdlc.ArtifactType) error {
if m.rejectArtifactFn != nil {
return m.rejectArtifactFn(ctx, podName, slug, artType)
}
return nil
}
func (m *mockSDLCExecutor) ListTasks(ctx context.Context, podName, slug string) ([]sdlc.Task, error) {
if m.listTasksFn != nil {
return m.listTasksFn(ctx, podName, slug)
}
return nil, nil
}
func (m *mockSDLCExecutor) AddTask(ctx context.Context, podName, slug, title string) (*sdlc.Task, error) {
if m.addTaskFn != nil {
return m.addTaskFn(ctx, podName, slug, title)
}
return &sdlc.Task{ID: "task-001", Title: title}, nil
}
func (m *mockSDLCExecutor) StartTask(ctx context.Context, podName, slug, taskID string) error {
if m.startTaskFn != nil {
return m.startTaskFn(ctx, podName, slug, taskID)
}
return nil
}
func (m *mockSDLCExecutor) CompleteTask(ctx context.Context, podName, slug, taskID string) error {
if m.completeTaskFn != nil {
return m.completeTaskFn(ctx, podName, slug, taskID)
}
return nil
}
func (m *mockSDLCExecutor) BlockTask(ctx context.Context, podName, slug, taskID string) error {
if m.blockTaskFn != nil {
return m.blockTaskFn(ctx, podName, slug, taskID)
}
return nil
}
func (m *mockSDLCExecutor) QueryBlocked(ctx context.Context, podName string) ([]port.BlockedInfo, error) {
if m.queryBlockedFn != nil {
return m.queryBlockedFn(ctx, podName)
}
return nil, nil
}
func (m *mockSDLCExecutor) QueryReady(ctx context.Context, podName string) ([]port.ReadyInfo, error) {
if m.queryReadyFn != nil {
return m.queryReadyFn(ctx, podName)
}
return nil, nil
}
func (m *mockSDLCExecutor) QueryNeedsApproval(ctx context.Context, podName string) ([]port.ApprovalInfo, error) {
if m.queryNeedsApprFn != nil {
return m.queryNeedsApprFn(ctx, podName)
}
return nil, nil
}
// mockProjectRepo implements port.ProjectRepository for testing.
type mockProjectRepo struct {
projects map[domain.ProjectID]*domain.Project
}
func newMockProjectRepo(projects ...*domain.Project) *mockProjectRepo {
m := &mockProjectRepo{projects: make(map[domain.ProjectID]*domain.Project)}
for _, p := range projects {
m.projects[p.ID] = p
}
return m
}
func (m *mockProjectRepo) Get(_ context.Context, id domain.ProjectID) (*domain.Project, error) {
p, ok := m.projects[id]
if !ok {
return nil, domain.ErrProjectNotFound
}
return p, nil
}
func (m *mockProjectRepo) List(_ context.Context) ([]domain.Project, error) { return nil, nil }
func (m *mockProjectRepo) Exists(_ context.Context, id domain.ProjectID) (bool, error) {
_, ok := m.projects[id]
return ok, nil
}
func (m *mockProjectRepo) Register(_ context.Context, _ *domain.Project) error { return nil }
func (m *mockProjectRepo) Unregister(_ context.Context, _ domain.ProjectID) error { return nil }
func (m *mockProjectRepo) RefreshStatus(_ context.Context) error { return nil }
func newTestService(exec port.SDLCExecutor, repo *mockProjectRepo) *SDLCService {
return NewSDLCService(exec, repo, SDLCServiceConfig{})
}
func TestSDLCService_GetState(t *testing.T) {
repo := newMockProjectRepo(&domain.Project{
ID: "myproj",
PodName: "myproj-pod",
})
exec := &mockSDLCExecutor{
getStateFn: func(_ context.Context, podName string) (*sdlc.State, error) {
if podName != "myproj-pod" {
t.Errorf("expected podName myproj-pod, got %s", podName)
}
return &sdlc.State{Version: 1}, nil
},
}
svc := newTestService(exec, repo)
state, err := svc.GetState(context.Background(), "myproj")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if state.Version != 1 {
t.Errorf("expected version 1, got %d", state.Version)
}
}
func TestSDLCService_ProjectNotFound(t *testing.T) {
repo := newMockProjectRepo() // empty
exec := &mockSDLCExecutor{}
svc := newTestService(exec, repo)
_, err := svc.GetState(context.Background(), "nonexistent")
if !errors.Is(err, domain.ErrProjectNotFound) {
t.Errorf("expected ErrProjectNotFound, got %v", err)
}
}
func TestSDLCService_TransitionFeature(t *testing.T) {
var calledSlug string
var calledPhase sdlc.FeaturePhase
repo := newMockProjectRepo(&domain.Project{
ID: "myproj",
PodName: "myproj-pod",
})
exec := &mockSDLCExecutor{
transitionFeatureFn: func(_ context.Context, _ string, slug string, phase sdlc.FeaturePhase) error {
calledSlug = slug
calledPhase = phase
return nil
},
}
svc := newTestService(exec, repo)
err := svc.TransitionFeature(context.Background(), "myproj", "auth-flow", sdlc.PhaseSpecified)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if calledSlug != "auth-flow" {
t.Errorf("expected slug auth-flow, got %s", calledSlug)
}
if calledPhase != sdlc.PhaseSpecified {
t.Errorf("expected phase specified, got %s", calledPhase)
}
}
func TestSDLCService_TransitionFeature_Error(t *testing.T) {
repo := newMockProjectRepo(&domain.Project{
ID: "myproj",
PodName: "myproj-pod",
})
exec := &mockSDLCExecutor{
transitionFeatureFn: func(_ context.Context, _, _ string, _ sdlc.FeaturePhase) error {
return sdlc.ErrInvalidTransition
},
}
svc := newTestService(exec, repo)
err := svc.TransitionFeature(context.Background(), "myproj", "auth-flow", sdlc.PhaseReview)
if !errors.Is(err, sdlc.ErrInvalidTransition) {
t.Errorf("expected ErrInvalidTransition, got %v", err)
}
}
func TestSDLCService_CreateFeature(t *testing.T) {
repo := newMockProjectRepo(&domain.Project{
ID: "myproj",
PodName: "myproj-pod",
})
exec := &mockSDLCExecutor{}
svc := newTestService(exec, repo)
f, err := svc.CreateFeature(context.Background(), "myproj", "new-feature", "New Feature")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if f.Slug != "new-feature" {
t.Errorf("expected slug new-feature, got %s", f.Slug)
}
}
func TestSDLCService_ApproveArtifact(t *testing.T) {
var calledArtType sdlc.ArtifactType
repo := newMockProjectRepo(&domain.Project{
ID: "myproj",
PodName: "myproj-pod",
})
exec := &mockSDLCExecutor{
approveArtifactFn: func(_ context.Context, _, _ string, artType sdlc.ArtifactType) error {
calledArtType = artType
return nil
},
}
svc := newTestService(exec, repo)
err := svc.ApproveArtifact(context.Background(), "myproj", "auth-flow", sdlc.ArtifactSpec)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if calledArtType != sdlc.ArtifactSpec {
t.Errorf("expected artifact type spec, got %s", calledArtType)
}
}
func TestSDLCService_QueryBlocked(t *testing.T) {
repo := newMockProjectRepo(&domain.Project{
ID: "myproj",
PodName: "myproj-pod",
})
exec := &mockSDLCExecutor{
queryBlockedFn: func(_ context.Context, _ string) ([]port.BlockedInfo, error) {
return []port.BlockedInfo{
{Slug: "auth", Phase: "implementation", Blockers: []string{"needs API key"}},
}, nil
},
}
svc := newTestService(exec, repo)
blocked, err := svc.QueryBlocked(context.Background(), "myproj")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(blocked) != 1 {
t.Fatalf("expected 1 blocked item, got %d", len(blocked))
}
if blocked[0].Slug != "auth" {
t.Errorf("expected slug auth, got %s", blocked[0].Slug)
}
}
func TestSDLCService_AddTask(t *testing.T) {
repo := newMockProjectRepo(&domain.Project{
ID: "myproj",
PodName: "myproj-pod",
})
exec := &mockSDLCExecutor{}
svc := newTestService(exec, repo)
task, err := svc.AddTask(context.Background(), "myproj", "auth-flow", "Add login form")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if task.Title != "Add login form" {
t.Errorf("expected title 'Add login form', got %s", task.Title)
}
}