diff --git a/.gitignore b/.gitignore index cdedf00..eff5848 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ Thumbs.db *.tar *.gz /rdev-api +/sdlc coverage.out # Temporary files diff --git a/CLAUDE.md b/CLAUDE.md index 03b9602..ea60f1d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/ai-lookup/index.md b/ai-lookup/index.md index e7fdb38..fa0b841 100644 --- a/ai-lookup/index.md +++ b/ai-lookup/index.md @@ -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 diff --git a/ai-lookup/services/sdlc.md b/ai-lookup/services/sdlc.md new file mode 100644 index 0000000..ffea6fa --- /dev/null +++ b/ai-lookup/services/sdlc.md @@ -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 # 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 diff --git a/cmd/rdev-api/main.go b/cmd/rdev-api/main.go index 63f91c4..9bc48da 100644 --- a/cmd/rdev-api/main.go +++ b/cmd/rdev-api/main.go @@ -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{ diff --git a/cmd/sdlc/cmd_artifact.go b/cmd/sdlc/cmd_artifact.go new file mode 100644 index 0000000..4477bbb --- /dev/null +++ b/cmd/sdlc/cmd_artifact.go @@ -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) +} diff --git a/cmd/sdlc/cmd_config.go b/cmd/sdlc/cmd_config.go new file mode 100644 index 0000000..c8eaa75 --- /dev/null +++ b/cmd/sdlc/cmd_config.go @@ -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) +} diff --git a/cmd/sdlc/cmd_feature.go b/cmd/sdlc/cmd_feature.go new file mode 100644 index 0000000..e1b8e8c --- /dev/null +++ b/cmd/sdlc/cmd_feature.go @@ -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) +} diff --git a/cmd/sdlc/cmd_init.go b/cmd/sdlc/cmd_init.go new file mode 100644 index 0000000..d922a09 --- /dev/null +++ b/cmd/sdlc/cmd_init.go @@ -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) +} diff --git a/cmd/sdlc/cmd_next.go b/cmd/sdlc/cmd_next.go new file mode 100644 index 0000000..07580b3 --- /dev/null +++ b/cmd/sdlc/cmd_next.go @@ -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) +} diff --git a/cmd/sdlc/cmd_query.go b/cmd/sdlc/cmd_query.go new file mode 100644 index 0000000..f5bda7d --- /dev/null +++ b/cmd/sdlc/cmd_query.go @@ -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) +} diff --git a/cmd/sdlc/cmd_state.go b/cmd/sdlc/cmd_state.go new file mode 100644 index 0000000..d5c1380 --- /dev/null +++ b/cmd/sdlc/cmd_state.go @@ -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) +} diff --git a/cmd/sdlc/cmd_task.go b/cmd/sdlc/cmd_task.go new file mode 100644 index 0000000..0553348 --- /dev/null +++ b/cmd/sdlc/cmd_task.go @@ -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) +} diff --git a/cmd/sdlc/main.go b/cmd/sdlc/main.go new file mode 100644 index 0000000..a5a50d9 --- /dev/null +++ b/cmd/sdlc/main.go @@ -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) + } +} diff --git a/cmd/sdlc/root.go b/cmd/sdlc/root.go new file mode 100644 index 0000000..a8ccc9b --- /dev/null +++ b/cmd/sdlc/root.go @@ -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)) +} diff --git a/cookbooks/VISION.md b/cookbooks/VISION.md index c943f20..1628244 100644 --- a/cookbooks/VISION.md +++ b/cookbooks/VISION.md @@ -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?** diff --git a/cookbooks/feature-development.md b/cookbooks/feature-development.md index ea4a6a5..90971e6 100644 --- a/cookbooks/feature-development.md +++ b/cookbooks/feature-development.md @@ -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 diff --git a/cookbooks/scripts/feature-test.sh b/cookbooks/scripts/feature-test.sh index 32e0116..d87ffb6 100755 --- a/cookbooks/scripts/feature-test.sh +++ b/cookbooks/scripts/feature-test.sh @@ -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 diff --git a/go.mod b/go.mod index a683346..b9fb0a0 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 9a5b63b..8682cb4 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/adapter/kubernetes/sdlc_executor.go b/internal/adapter/kubernetes/sdlc_executor.go new file mode 100644 index 0000000..e09125c --- /dev/null +++ b/internal/adapter/kubernetes/sdlc_executor.go @@ -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) diff --git a/internal/adapter/kubernetes/sdlc_executor_test.go b/internal/adapter/kubernetes/sdlc_executor_test.go new file mode 100644 index 0000000..105a92f --- /dev/null +++ b/internal/adapter/kubernetes/sdlc_executor_test.go @@ -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") + } +} diff --git a/internal/adapter/templates/templates/components/service/.env.example.tmpl b/internal/adapter/templates/templates/components/service/.env.example.tmpl index 5a631b0..f01de5f 100644 --- a/internal/adapter/templates/templates/components/service/.env.example.tmpl +++ b/internal/adapter/templates/templates/components/service/.env.example.tmpl @@ -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 diff --git a/internal/adapter/templates/templates/components/service/internal/api/handlers/example.go.tmpl b/internal/adapter/templates/templates/components/service/internal/api/handlers/example.go.tmpl index 98afbce..38104f7 100644 --- a/internal/adapter/templates/templates/components/service/internal/api/handlers/example.go.tmpl +++ b/internal/adapter/templates/templates/components/service/internal/api/handlers/example.go.tmpl @@ -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 +} diff --git a/internal/adapter/templates/templates/components/service/internal/api/handlers/example_test.go.tmpl b/internal/adapter/templates/templates/components/service/internal/api/handlers/example_test.go.tmpl new file mode 100644 index 0000000..03a76e7 --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/api/handlers/example_test.go.tmpl @@ -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) + } +} diff --git a/internal/adapter/templates/templates/components/service/internal/api/routes.go.tmpl b/internal/adapter/templates/templates/components/service/internal/api/routes.go.tmpl index c0c5f92..ee3dae7 100644 --- a/internal/adapter/templates/templates/components/service/internal/api/routes.go.tmpl +++ b/internal/adapter/templates/templates/components/service/internal/api/routes.go.tmpl @@ -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)) + }) }) } diff --git a/internal/adapter/templates/templates/components/service/internal/api/spec.go.tmpl b/internal/adapter/templates/templates/components/service/internal/api/spec.go.tmpl new file mode 100644 index 0000000..4ef3351 --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/api/spec.go.tmpl @@ -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 +} diff --git a/internal/adapter/templates/templates/components/service/internal/config/config.go.tmpl b/internal/adapter/templates/templates/components/service/internal/config/config.go.tmpl index 884b9d1..0152c87 100644 --- a/internal/adapter/templates/templates/components/service/internal/config/config.go.tmpl +++ b/internal/adapter/templates/templates/components/service/internal/config/config.go.tmpl @@ -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"), + } } diff --git a/internal/adapter/templates/templates/skeleton/.claude/guides/backend/api-patterns.md.tmpl b/internal/adapter/templates/templates/skeleton/.claude/guides/backend/api-patterns.md.tmpl new file mode 100644 index 0000000..246f7fd --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/.claude/guides/backend/api-patterns.md.tmpl @@ -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)) +``` diff --git a/internal/adapter/templates/templates/skeleton/.claude/guides/frontend/design-system.md.tmpl b/internal/adapter/templates/templates/skeleton/.claude/guides/frontend/design-system.md.tmpl new file mode 100644 index 0000000..3c99a6b --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/.claude/guides/frontend/design-system.md.tmpl @@ -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). diff --git a/internal/adapter/templates/templates/skeleton/CLAUDE.md.tmpl b/internal/adapter/templates/templates/skeleton/CLAUDE.md.tmpl index 563b2d3..436e09c 100644 --- a/internal/adapter/templates/templates/skeleton/CLAUDE.md.tmpl +++ b/internal/adapter/templates/templates/skeleton/CLAUDE.md.tmpl @@ -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 | diff --git a/internal/adapter/templates/templates/skeleton/packages/ui/package.json.tmpl b/internal/adapter/templates/templates/skeleton/packages/ui/package.json.tmpl index cef6a54..575d1da 100644 --- a/internal/adapter/templates/templates/skeleton/packages/ui/package.json.tmpl +++ b/internal/adapter/templates/templates/skeleton/packages/ui/package.json.tmpl @@ -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", diff --git a/internal/adapter/templates/templates/skeleton/packages/ui/src/components/Alert.tsx b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/Alert.tsx new file mode 100644 index 0000000..f3f8349 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/Alert.tsx @@ -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 }; diff --git a/internal/adapter/templates/templates/skeleton/packages/ui/src/components/DropdownMenu.tsx b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/DropdownMenu.tsx new file mode 100644 index 0000000..1263de6 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/DropdownMenu.tsx @@ -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, +}; diff --git a/internal/adapter/templates/templates/skeleton/packages/ui/src/components/Sheet.tsx b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/Sheet.tsx new file mode 100644 index 0000000..beea11a --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/Sheet.tsx @@ -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, +}; diff --git a/internal/adapter/templates/templates/skeleton/packages/ui/src/components/Textarea.tsx b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/Textarea.tsx new file mode 100644 index 0000000..f27eca2 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/Textarea.tsx @@ -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 }; diff --git a/internal/adapter/templates/templates/skeleton/packages/ui/src/index.ts b/internal/adapter/templates/templates/skeleton/packages/ui/src/index.ts index c3aa2af..693319c 100644 --- a/internal/adapter/templates/templates/skeleton/packages/ui/src/index.ts +++ b/internal/adapter/templates/templates/skeleton/packages/ui/src/index.ts @@ -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 { diff --git a/internal/adapter/templates/templates/skeleton/pkg/app/app.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/app/app.go.tmpl index 5803ecc..b9ec81b 100644 --- a/internal/adapter/templates/templates/skeleton/pkg/app/app.go.tmpl +++ b/internal/adapter/templates/templates/skeleton/pkg/app/app.go.tmpl @@ -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) diff --git a/internal/adapter/templates/templates/skeleton/pkg/chassis/chassis.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/chassis/chassis.go.tmpl new file mode 100644 index 0000000..d24f163 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/pkg/chassis/chassis.go.tmpl @@ -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 +) diff --git a/internal/adapter/templates/templates/skeleton/pkg/go.mod.tmpl b/internal/adapter/templates/templates/skeleton/pkg/go.mod.tmpl index b675191..5408061 100644 --- a/internal/adapter/templates/templates/skeleton/pkg/go.mod.tmpl +++ b/internal/adapter/templates/templates/skeleton/pkg/go.mod.tmpl @@ -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 ) diff --git a/internal/adapter/templates/templates/skeleton/pkg/openapi/docs.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/openapi/docs.go.tmpl new file mode 100644 index 0000000..7ced3a6 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/pkg/openapi/docs.go.tmpl @@ -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) + }) +} diff --git a/internal/adapter/templates/templates/skeleton/pkg/openapi/params.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/openapi/params.go.tmpl new file mode 100644 index 0000000..42415fe --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/pkg/openapi/params.go.tmpl @@ -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()), + } +} diff --git a/internal/adapter/templates/templates/skeleton/pkg/openapi/schema.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/openapi/schema.go.tmpl new file mode 100644 index 0000000..1c4711b --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/pkg/openapi/schema.go.tmpl @@ -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")) +} diff --git a/internal/adapter/templates/templates/skeleton/pkg/openapi/spec.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/openapi/spec.go.tmpl new file mode 100644 index 0000000..861d8ee --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/pkg/openapi/spec.go.tmpl @@ -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"}, + }, + } +} diff --git a/internal/domain/component.go b/internal/domain/component.go index 6e9602e..bde0bac 100644 --- a/internal/domain/component.go +++ b/internal/domain/component.go @@ -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). diff --git a/internal/domain/component_test.go b/internal/domain/component_test.go index 731fcbb..ff79855 100644 --- a/internal/domain/component_test.go +++ b/internal/domain/component_test.go @@ -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, } diff --git a/internal/handlers/sdlc.go b/internal/handlers/sdlc.go new file mode 100644 index 0000000..a304769 --- /dev/null +++ b/internal/handlers/sdlc.go @@ -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") + } +} diff --git a/internal/handlers/sdlc_artifacts.go b/internal/handlers/sdlc_artifacts.go new file mode 100644 index 0000000..b416837 --- /dev/null +++ b/internal/handlers/sdlc_artifacts.go @@ -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", + }) +} diff --git a/internal/handlers/sdlc_features.go b/internal/handlers/sdlc_features.go new file mode 100644 index 0000000..a26781e --- /dev/null +++ b/internal/handlers/sdlc_features.go @@ -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) +} diff --git a/internal/handlers/sdlc_queries.go b/internal/handlers/sdlc_queries.go new file mode 100644 index 0000000..6927498 --- /dev/null +++ b/internal/handlers/sdlc_queries.go @@ -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) +} diff --git a/internal/handlers/sdlc_tasks.go b/internal/handlers/sdlc_tasks.go new file mode 100644 index 0000000..0b6285d --- /dev/null +++ b/internal/handlers/sdlc_tasks.go @@ -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", + }) +} diff --git a/internal/handlers/sdlc_test.go b/internal/handlers/sdlc_test.go new file mode 100644 index 0000000..bd97a18 --- /dev/null +++ b/internal/handlers/sdlc_test.go @@ -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()) + } +} diff --git a/internal/port/sdlc_executor.go b/internal/port/sdlc_executor.go new file mode 100644 index 0000000..d1de2e7 --- /dev/null +++ b/internal/port/sdlc_executor.go @@ -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"` +} diff --git a/internal/sdlc/artifact.go b/internal/sdlc/artifact.go new file mode 100644 index 0000000..7e1795e --- /dev/null +++ b/internal/sdlc/artifact.go @@ -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 +} diff --git a/internal/sdlc/classifier.go b/internal/sdlc/classifier.go new file mode 100644 index 0000000..b221083 --- /dev/null +++ b/internal/sdlc/classifier.go @@ -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", + } +} diff --git a/internal/sdlc/classifier_test.go b/internal/sdlc/classifier_test.go new file mode 100644 index 0000000..8190fe6 --- /dev/null +++ b/internal/sdlc/classifier_test.go @@ -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) + } +} diff --git a/internal/sdlc/config.go b/internal/sdlc/config.go new file mode 100644 index 0000000..3f1ce47 --- /dev/null +++ b/internal/sdlc/config.go @@ -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) +} diff --git a/internal/sdlc/config_test.go b/internal/sdlc/config_test.go new file mode 100644 index 0000000..a929589 --- /dev/null +++ b/internal/sdlc/config_test.go @@ -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) + } +} diff --git a/internal/sdlc/errors.go b/internal/sdlc/errors.go new file mode 100644 index 0000000..ea299cc --- /dev/null +++ b/internal/sdlc/errors.go @@ -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") +) diff --git a/internal/sdlc/feature.go b/internal/sdlc/feature.go new file mode 100644 index 0000000..164d3b0 --- /dev/null +++ b/internal/sdlc/feature.go @@ -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 + } +} diff --git a/internal/sdlc/feature_test.go b/internal/sdlc/feature_test.go new file mode 100644 index 0000000..ea6d37b --- /dev/null +++ b/internal/sdlc/feature_test.go @@ -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") + } +} diff --git a/internal/sdlc/history.go b/internal/sdlc/history.go new file mode 100644 index 0000000..119d786 --- /dev/null +++ b/internal/sdlc/history.go @@ -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"` +} diff --git a/internal/sdlc/init.go b/internal/sdlc/init.go new file mode 100644 index 0000000..9cab4dd --- /dev/null +++ b/internal/sdlc/init.go @@ -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() +} diff --git a/internal/sdlc/init_test.go b/internal/sdlc/init_test.go new file mode 100644 index 0000000..32a2417 --- /dev/null +++ b/internal/sdlc/init_test.go @@ -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") + } +} diff --git a/internal/sdlc/paths.go b/internal/sdlc/paths.go new file mode 100644 index 0000000..b669ecd --- /dev/null +++ b/internal/sdlc/paths.go @@ -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, + } +} diff --git a/internal/sdlc/paths_test.go b/internal/sdlc/paths_test.go new file mode 100644 index 0000000..265359d --- /dev/null +++ b/internal/sdlc/paths_test.go @@ -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") + } +} diff --git a/internal/sdlc/rules.go b/internal/sdlc/rules.go new file mode 100644 index 0000000..b6895ba --- /dev/null +++ b/internal/sdlc/rules.go @@ -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 + }, + } +} diff --git a/internal/sdlc/state.go b/internal/sdlc/state.go new file mode 100644 index 0000000..dceaa50 --- /dev/null +++ b/internal/sdlc/state.go @@ -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{}, + } +} diff --git a/internal/sdlc/state_test.go b/internal/sdlc/state_test.go new file mode 100644 index 0000000..fe2f59b --- /dev/null +++ b/internal/sdlc/state_test.go @@ -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) + } +} diff --git a/internal/sdlc/task.go b/internal/sdlc/task.go new file mode 100644 index 0000000..242913d --- /dev/null +++ b/internal/sdlc/task.go @@ -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 +} diff --git a/internal/sdlc/task_test.go b/internal/sdlc/task_test.go new file mode 100644 index 0000000..70fc8ff --- /dev/null +++ b/internal/sdlc/task_test.go @@ -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) + } +} diff --git a/internal/sdlc/types.go b/internal/sdlc/types.go new file mode 100644 index 0000000..865f4f7 --- /dev/null +++ b/internal/sdlc/types.go @@ -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"` +} diff --git a/internal/service/sdlc_service.go b/internal/service/sdlc_service.go new file mode 100644 index 0000000..410adcc --- /dev/null +++ b/internal/service/sdlc_service.go @@ -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) +} diff --git a/internal/service/sdlc_service_test.go b/internal/service/sdlc_service_test.go new file mode 100644 index 0000000..08fdc80 --- /dev/null +++ b/internal/service/sdlc_service_test.go @@ -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) + } +}