feat: add SDLC orchestration - library, CLI, and API integration
Implements deterministic feature lifecycle management for agent-driven
development. Agents use the CLI in pods; operators control via REST API.
Library (internal/sdlc/):
- Feature lifecycle with 10 phases (draft → released)
- Classifier engine with priority-ordered rules
- Artifact tracking with approval workflow
- Task management within features
- YAML-based state persistence
CLI (cmd/sdlc/):
- init, state, next, feature, artifact, task, query commands
- --json flag for machine-readable output
- Runs inside project pods
API (21 endpoints under /projects/{id}/sdlc/):
- State: GET /state, GET /next
- Features: CRUD + transition/block/unblock
- Artifacts: approve/reject per type
- Tasks: add/start/complete/block
- Queries: blocked/ready/needs-approval
Architecture:
- Port: SDLCExecutor interface (internal/port/)
- Adapter: kubectl exec into pods (internal/adapter/kubernetes/)
- Service: pod resolution + logging (internal/service/)
- Handlers: 5 files under 500-line limit (internal/handlers/)
Also includes template upgrades (chassis framework, UI components,
OpenAPI helpers, backend/frontend guides) and component improvements.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
62460bf098
commit
425ef0f806
1
.gitignore
vendored
1
.gitignore
vendored
@ -26,6 +26,7 @@ Thumbs.db
|
|||||||
*.tar
|
*.tar
|
||||||
*.gz
|
*.gz
|
||||||
/rdev-api
|
/rdev-api
|
||||||
|
/sdlc
|
||||||
coverage.out
|
coverage.out
|
||||||
|
|
||||||
# Temporary files
|
# Temporary files
|
||||||
|
|||||||
@ -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) |
|
| **Redis operations** | [services/redis.md](.claude/guides/services/redis.md) |
|
||||||
| **DNS / Cloudflare** | [services/dns-cloudflare.md](.claude/guides/services/dns-cloudflare.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) |
|
| **Network policies / internal routing** | [ops/networking.md](.claude/guides/ops/networking.md) |
|
||||||
|
| **SDLC orchestration** | [services/sdlc.md](.claude/guides/services/sdlc.md) |
|
||||||
|
|
||||||
## Critical Rules
|
## 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/rdev-api/ # Entry point, DI, OpenAPI spec
|
||||||
|
cmd/sdlc/ # SDLC CLI binary (runs inside project pods)
|
||||||
internal/
|
internal/
|
||||||
|
├── sdlc/ # SDLC library (types, classifier, state I/O)
|
||||||
├── domain/ # Pure business models (no deps)
|
├── domain/ # Pure business models (no deps)
|
||||||
├── port/ # Interface contracts
|
├── port/ # Interface contracts
|
||||||
├── service/ # Business logic orchestration
|
├── service/ # Business logic orchestration
|
||||||
@ -163,6 +166,7 @@ cookbooks/ # End-to-end workflow guides
|
|||||||
| Database Provisioning | **Done** | CockroachDB adapter with auto-provisioning |
|
| Database Provisioning | **Done** | CockroachDB adapter with auto-provisioning |
|
||||||
| Cache Provisioning | **Done** | Redis ACL-based adapter with auto-provisioning |
|
| Cache Provisioning | **Done** | Redis ACL-based adapter with auto-provisioning |
|
||||||
| Build Orchestration | Planned | Structured build specs via API |
|
| 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) |
|
| Composable Monorepo Templates | **Done** | Monorepo skeleton + component templates (service, worker, app-astro, app-react, cli) |
|
||||||
|
|
||||||
**Current Version:** v0.10.25
|
**Current Version:** v0.10.25
|
||||||
|
|||||||
@ -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 |
|
| 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 |
|
| 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 |
|
| 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
|
## Roadmap Reference
|
||||||
|
|
||||||
|
|||||||
88
ai-lookup/services/sdlc.md
Normal file
88
ai-lookup/services/sdlc.md
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
# SDLC Orchestration
|
||||||
|
|
||||||
|
**Last Updated:** 2026-02
|
||||||
|
**Confidence:** High (Steps 1-5 implemented, Step 6 pending)
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Deterministic feature lifecycle management. Classifier engine evaluates priority-ordered rules to determine the next required action. State lives in `.sdlc/` directory (git-tracked). Used by Claude agents in pods (CLI) and by rdev API (Go library + kubectl exec).
|
||||||
|
|
||||||
|
**Key Facts:**
|
||||||
|
- 10 phases: draft → specified → planned → ready → implementation → review → audit → qa → merge → released
|
||||||
|
- 24 classifier rules, first match wins, returns Classification with action + guidance
|
||||||
|
- 7 artifact types: spec, design, tasks, qa_plan, review, audit, qa_results
|
||||||
|
- rdev drives transitions on behalf of users (approve, reject, unblock, transition)
|
||||||
|
- Multi-project: scoped by projectID (pod name), each pod has its own `.sdlc/`
|
||||||
|
|
||||||
|
**File Pointers:**
|
||||||
|
- Library: `internal/sdlc/` (types, state, feature, classifier, rules, config)
|
||||||
|
- CLI: `cmd/sdlc/` (cobra commands, --json output for API consumption)
|
||||||
|
- Port (planned): `internal/port/sdlc_executor.go`
|
||||||
|
- Adapter (planned): `internal/adapter/kubernetes/sdlc_adapter.go`
|
||||||
|
- Handler (planned): `internal/handlers/sdlc.go`
|
||||||
|
- Spec: `docs/specs/sdlc-orchestration-system.md`
|
||||||
|
- Guide: `.claude/guides/services/sdlc.md`
|
||||||
|
|
||||||
|
## Library Types
|
||||||
|
|
||||||
|
```go
|
||||||
|
// internal/sdlc/classifier.go
|
||||||
|
type Classification struct {
|
||||||
|
Feature string `json:"feature"`
|
||||||
|
CurrentPhase FeaturePhase `json:"current_phase"`
|
||||||
|
RuleMatched string `json:"rule_matched"`
|
||||||
|
Action ActionType `json:"action"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
NextCommand string `json:"next_command,omitempty"`
|
||||||
|
OutputPath string `json:"output_path,omitempty"`
|
||||||
|
TransitionTo FeaturePhase `json:"transition_to,omitempty"`
|
||||||
|
TaskID string `json:"task_id,omitempty"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI Commands
|
||||||
|
|
||||||
|
```
|
||||||
|
sdlc init # Create .sdlc/ structure
|
||||||
|
sdlc state [--json] # Full state dump
|
||||||
|
sdlc feature create <slug> <title> # New feature
|
||||||
|
sdlc feature transition <slug> <phase> # Phase transition
|
||||||
|
sdlc feature block/unblock <slug> # Blocker management
|
||||||
|
sdlc artifact create/approve/reject # Artifact lifecycle
|
||||||
|
sdlc task add/start/complete/block # Task lifecycle
|
||||||
|
sdlc next [--for <feature>] [--json] # Classifier output
|
||||||
|
sdlc query blocked/ready/needs-approval # Queries
|
||||||
|
```
|
||||||
|
|
||||||
|
## rdev API Endpoints (Planned - Step 6)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /projects/{id}/sdlc/state
|
||||||
|
GET /projects/{id}/sdlc/next
|
||||||
|
GET /projects/{id}/sdlc/features
|
||||||
|
GET /projects/{id}/sdlc/features/{slug}
|
||||||
|
POST /projects/{id}/sdlc/features/{slug}/transition
|
||||||
|
POST /projects/{id}/sdlc/features/{slug}/artifacts/{type}/approve
|
||||||
|
POST /projects/{id}/sdlc/features/{slug}/artifacts/{type}/reject
|
||||||
|
POST /projects/{id}/sdlc/features/{slug}/unblock
|
||||||
|
GET /projects/{id}/sdlc/query/blocked
|
||||||
|
GET /projects/{id}/sdlc/query/ready
|
||||||
|
GET /projects/{id}/sdlc/query/needs-approval
|
||||||
|
```
|
||||||
|
|
||||||
|
## Feature Gaps (Step 6)
|
||||||
|
|
||||||
|
| Gap | Description | Effort |
|
||||||
|
|-----|-------------|--------|
|
||||||
|
| Port interface | `SDLCExecutor` port for pod operations | Small |
|
||||||
|
| Pod adapter | kubectl exec wrapper for sdlc commands | Medium |
|
||||||
|
| Service layer | Business logic, validation, error mapping | Medium |
|
||||||
|
| HTTP handlers | REST endpoints under `/projects/{id}/sdlc/` | Medium |
|
||||||
|
| Cross-project view | Dashboard of all project SDLC states | Small |
|
||||||
|
| Webhook events | SDLC phase transitions as webhook events | Small |
|
||||||
|
|
||||||
|
## Related Topics
|
||||||
|
|
||||||
|
- [Kubernetes Adapter](./kubernetes.md) - Pod execution pattern (PodGitOperations)
|
||||||
|
- [Work Queue](./work-queue.md) - Task execution for agents
|
||||||
|
- [Worker Pool](./worker-pool.md) - Agent pool management
|
||||||
@ -241,6 +241,10 @@ func main() {
|
|||||||
// Create build service (orchestrates build submission and tracking)
|
// Create build service (orchestrates build submission and tracking)
|
||||||
buildService := service.NewBuildService(workQueueRepo, buildAuditRepo, logger)
|
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
|
// Create app
|
||||||
app := api.New("rdev-api",
|
app := api.New("rdev-api",
|
||||||
api.WithPort(cfg.Port),
|
api.WithPort(cfg.Port),
|
||||||
@ -368,6 +372,8 @@ func main() {
|
|||||||
buildsHandler := handlers.NewBuildsHandler(buildService)
|
buildsHandler := handlers.NewBuildsHandler(buildService)
|
||||||
createAndBuildHandler := handlers.NewCreateAndBuildHandler(projectInfraService, buildService, logger)
|
createAndBuildHandler := handlers.NewCreateAndBuildHandler(projectInfraService, buildService, logger)
|
||||||
|
|
||||||
|
sdlcHandler := handlers.NewSDLCHandler(sdlcService, logger)
|
||||||
|
|
||||||
// Initialize operations handler (for debugging project failures)
|
// Initialize operations handler (for debugging project failures)
|
||||||
operationsHandler := handlers.NewOperationsHandler(operationRepo)
|
operationsHandler := handlers.NewOperationsHandler(operationRepo)
|
||||||
|
|
||||||
@ -398,6 +404,7 @@ func main() {
|
|||||||
buildsHandler.Mount(app.Router())
|
buildsHandler.Mount(app.Router())
|
||||||
createAndBuildHandler.Mount(app.Router())
|
createAndBuildHandler.Mount(app.Router())
|
||||||
operationsHandler.Mount(app.Router())
|
operationsHandler.Mount(app.Router())
|
||||||
|
sdlcHandler.Mount(app.Router())
|
||||||
|
|
||||||
// Start queue processor worker (per-project command queue)
|
// Start queue processor worker (per-project command queue)
|
||||||
queueProcessor := worker.NewQueueProcessor(
|
queueProcessor := worker.NewQueueProcessor(
|
||||||
@ -415,9 +422,7 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start work executor (cross-project worker pool)
|
// Start work executor (cross-project worker pool, git via kubectl exec)
|
||||||
// PodGitOperations runs git commands inside the pod via kubectl exec.
|
|
||||||
// This ensures deterministic post-build commit/push instead of relying on LLMs.
|
|
||||||
var podGitOps *worker.PodGitOperations
|
var podGitOps *worker.PodGitOperations
|
||||||
if infraCfg.GiteaToken != "" {
|
if infraCfg.GiteaToken != "" {
|
||||||
podGitOps = worker.NewPodGitOperations(worker.PodGitOperationsConfig{
|
podGitOps = worker.NewPodGitOperations(worker.PodGitOperationsConfig{
|
||||||
|
|||||||
195
cmd/sdlc/cmd_artifact.go
Normal file
195
cmd/sdlc/cmd_artifact.go
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/orchard9/rdev/internal/sdlc"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var artifactCmd = &cobra.Command{
|
||||||
|
Use: "artifact",
|
||||||
|
Short: "Manage feature artifacts",
|
||||||
|
}
|
||||||
|
|
||||||
|
var artifactCreateCmd = &cobra.Command{
|
||||||
|
Use: "create <feature> <type>",
|
||||||
|
Short: "Create an artifact file (sets status to draft)",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(_ *cobra.Command, args []string) error {
|
||||||
|
root := mustResolveRoot()
|
||||||
|
slug, artTypeStr := args[0], args[1]
|
||||||
|
|
||||||
|
artType := sdlc.ArtifactType(artTypeStr)
|
||||||
|
if !sdlc.IsValidArtifactType(artType) {
|
||||||
|
return fmt.Errorf("invalid artifact type: %s (valid: spec, design, tasks, qa_plan, review, audit, qa_results)", artTypeStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := sdlc.LoadFeature(root, slug)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the artifact file if it doesn't exist
|
||||||
|
path := sdlc.ArtifactPath(root, slug, artType)
|
||||||
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||||
|
if err := os.WriteFile(path, fmt.Appendf(nil, "# %s: %s\n\n", artType, f.Title), 0o644); err != nil {
|
||||||
|
return fmt.Errorf("create artifact file: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update manifest
|
||||||
|
art := f.GetArtifact(artType)
|
||||||
|
if art == nil {
|
||||||
|
art = sdlc.NewArtifact(artType)
|
||||||
|
f.SetArtifact(artType, art)
|
||||||
|
}
|
||||||
|
art.MarkDraft()
|
||||||
|
|
||||||
|
if err := f.Save(root); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
|
printJSON(map[string]string{
|
||||||
|
"feature": slug,
|
||||||
|
"artifact": string(artType),
|
||||||
|
"status": string(art.Status),
|
||||||
|
"path": path,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Created artifact: %s/%s\n", slug, artType)
|
||||||
|
fmt.Printf(" Status: %s\n", art.Status)
|
||||||
|
fmt.Printf(" Path: %s\n", path)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var artifactApproveCmd = &cobra.Command{
|
||||||
|
Use: "approve <feature> <type>",
|
||||||
|
Short: "Approve an artifact",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(_ *cobra.Command, args []string) error {
|
||||||
|
root := mustResolveRoot()
|
||||||
|
slug, artTypeStr := args[0], args[1]
|
||||||
|
artType := sdlc.ArtifactType(artTypeStr)
|
||||||
|
|
||||||
|
f, err := sdlc.LoadFeature(root, slug)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
art := f.GetArtifact(artType)
|
||||||
|
if art == nil {
|
||||||
|
return sdlc.ErrArtifactNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
art.Approve("user")
|
||||||
|
if err := f.Save(root); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record in state
|
||||||
|
state, err := sdlc.LoadState(root)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
state.RecordAction("APPROVE_ARTIFACT", slug, "user")
|
||||||
|
if err := state.Save(root); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
|
printJSON(map[string]string{
|
||||||
|
"feature": slug,
|
||||||
|
"artifact": string(artType),
|
||||||
|
"status": "approved",
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Approved: %s/%s\n", slug, artType)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var artifactRejectCmd = &cobra.Command{
|
||||||
|
Use: "reject <feature> <type>",
|
||||||
|
Short: "Reject an artifact",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(_ *cobra.Command, args []string) error {
|
||||||
|
root := mustResolveRoot()
|
||||||
|
slug, artTypeStr := args[0], args[1]
|
||||||
|
artType := sdlc.ArtifactType(artTypeStr)
|
||||||
|
|
||||||
|
f, err := sdlc.LoadFeature(root, slug)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
art := f.GetArtifact(artType)
|
||||||
|
if art == nil {
|
||||||
|
return sdlc.ErrArtifactNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
art.Reject("user")
|
||||||
|
if err := f.Save(root); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
|
printJSON(map[string]string{
|
||||||
|
"feature": slug,
|
||||||
|
"artifact": string(artType),
|
||||||
|
"status": "rejected",
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Rejected: %s/%s\n", slug, artType)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var artifactStatusCmd = &cobra.Command{
|
||||||
|
Use: "status <feature>",
|
||||||
|
Short: "Show all artifact statuses for a feature",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(_ *cobra.Command, args []string) error {
|
||||||
|
root := mustResolveRoot()
|
||||||
|
|
||||||
|
f, err := sdlc.LoadFeature(root, args[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
|
printJSON(f.Artifacts)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Artifacts for %s:\n", f.Slug)
|
||||||
|
for _, at := range sdlc.ValidArtifactTypes {
|
||||||
|
art := f.GetArtifact(at)
|
||||||
|
if art == nil {
|
||||||
|
fmt.Printf(" %-12s -\n", at)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Printf(" %-12s %s\n", at, art.Status)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
artifactCmd.AddCommand(
|
||||||
|
artifactCreateCmd,
|
||||||
|
artifactApproveCmd,
|
||||||
|
artifactRejectCmd,
|
||||||
|
artifactStatusCmd,
|
||||||
|
)
|
||||||
|
rootCmd.AddCommand(artifactCmd)
|
||||||
|
}
|
||||||
104
cmd/sdlc/cmd_config.go
Normal file
104
cmd/sdlc/cmd_config.go
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/orchard9/rdev/internal/sdlc"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var configCmd = &cobra.Command{
|
||||||
|
Use: "config",
|
||||||
|
Short: "Manage SDLC configuration",
|
||||||
|
}
|
||||||
|
|
||||||
|
var configShowCmd = &cobra.Command{
|
||||||
|
Use: "show",
|
||||||
|
Short: "Show current configuration",
|
||||||
|
RunE: func(_ *cobra.Command, _ []string) error {
|
||||||
|
root := mustResolveRoot()
|
||||||
|
|
||||||
|
cfg, err := sdlc.LoadConfig(root)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
|
printJSON(cfg)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("SDLC Config (v%d)\n", cfg.Version)
|
||||||
|
fmt.Printf(" Project: %s\n", cfg.Project.Name)
|
||||||
|
if cfg.Project.Type != "" {
|
||||||
|
fmt.Printf(" Type: %s\n", cfg.Project.Type)
|
||||||
|
}
|
||||||
|
fmt.Printf(" Main: %s\n", cfg.Branches.Main)
|
||||||
|
fmt.Printf(" Prefix: %s\n", cfg.Branches.FeaturePrefix)
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
fmt.Println("Enabled Phases:")
|
||||||
|
for _, p := range cfg.Phases.Enabled {
|
||||||
|
fmt.Printf(" - %s\n", p)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
fmt.Println("Compliance:")
|
||||||
|
fmt.Printf(" Require Approvals: %v\n", cfg.Compliance.RequireApprovals)
|
||||||
|
fmt.Printf(" Require Branch: %v\n", cfg.Compliance.RequireBranch)
|
||||||
|
fmt.Printf(" Require QA: %v\n", cfg.Compliance.RequireQA)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var configSetCmd = &cobra.Command{
|
||||||
|
Use: "set <key> <value>",
|
||||||
|
Short: "Set a configuration value",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(_ *cobra.Command, args []string) error {
|
||||||
|
root := mustResolveRoot()
|
||||||
|
|
||||||
|
cfg, err := sdlc.LoadConfig(root)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
key, value := args[0], args[1]
|
||||||
|
switch key {
|
||||||
|
case "project.name":
|
||||||
|
cfg.Project.Name = value
|
||||||
|
case "project.type":
|
||||||
|
cfg.Project.Type = value
|
||||||
|
case "branches.main":
|
||||||
|
cfg.Branches.Main = value
|
||||||
|
case "branches.feature_prefix":
|
||||||
|
cfg.Branches.FeaturePrefix = value
|
||||||
|
case "compliance.require_approvals":
|
||||||
|
cfg.Compliance.RequireApprovals = value == "true"
|
||||||
|
case "compliance.require_branch":
|
||||||
|
cfg.Compliance.RequireBranch = value == "true"
|
||||||
|
case "compliance.require_qa":
|
||||||
|
cfg.Compliance.RequireQA = value == "true"
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown config key: %s", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cfg.Save(root); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
|
printJSON(map[string]string{"key": key, "value": value, "status": "set"})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Set %s = %s\n", key, value)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
configCmd.AddCommand(configShowCmd, configSetCmd)
|
||||||
|
rootCmd.AddCommand(configCmd)
|
||||||
|
}
|
||||||
321
cmd/sdlc/cmd_feature.go
Normal file
321
cmd/sdlc/cmd_feature.go
Normal file
@ -0,0 +1,321 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/orchard9/rdev/internal/sdlc"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var featureTitle string
|
||||||
|
|
||||||
|
var featureCmd = &cobra.Command{
|
||||||
|
Use: "feature",
|
||||||
|
Short: "Manage features",
|
||||||
|
}
|
||||||
|
|
||||||
|
var featureCreateCmd = &cobra.Command{
|
||||||
|
Use: "create <slug>",
|
||||||
|
Short: "Create a new feature",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(_ *cobra.Command, args []string) error {
|
||||||
|
root := mustResolveRoot()
|
||||||
|
slug := args[0]
|
||||||
|
|
||||||
|
title := featureTitle
|
||||||
|
if title == "" {
|
||||||
|
title = slug
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := sdlc.CreateFeature(root, slug, title)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to active work in state
|
||||||
|
state, err := sdlc.LoadState(root)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
state.AddActiveFeature(slug, sdlc.PhaseDraft)
|
||||||
|
state.RecordAction("CREATE_FEATURE", slug, "cli")
|
||||||
|
if err := state.Save(root); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
|
printJSON(f)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Created feature: %s\n", slug)
|
||||||
|
fmt.Printf(" Title: %s\n", f.Title)
|
||||||
|
fmt.Printf(" Phase: %s\n", f.Phase)
|
||||||
|
fmt.Printf(" Path: .sdlc/features/%s/\n", slug)
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Printf("Next: sdlc next --for %s\n", slug)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var featureListCmd = &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List all features",
|
||||||
|
RunE: func(_ *cobra.Command, _ []string) error {
|
||||||
|
root := mustResolveRoot()
|
||||||
|
|
||||||
|
features, err := sdlc.ListFeatures(root)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
|
printJSON(features)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(features) == 0 {
|
||||||
|
fmt.Println("No features found.")
|
||||||
|
fmt.Println("Create one: sdlc feature create <slug> --title \"Feature Name\"")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Features:")
|
||||||
|
for _, f := range features {
|
||||||
|
summary := sdlc.SummarizeTasks(f.Tasks)
|
||||||
|
taskInfo := ""
|
||||||
|
if summary.Total > 0 {
|
||||||
|
taskInfo = fmt.Sprintf(" (%d/%d tasks)", summary.Completed, summary.Total)
|
||||||
|
}
|
||||||
|
blocked := ""
|
||||||
|
if f.IsBlocked() {
|
||||||
|
blocked = " [BLOCKED]"
|
||||||
|
}
|
||||||
|
fmt.Printf(" %-20s [%-15s]%s%s\n", f.Slug, f.Phase, taskInfo, blocked)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var featureShowCmd = &cobra.Command{
|
||||||
|
Use: "show <slug>",
|
||||||
|
Short: "Show feature details",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(_ *cobra.Command, args []string) error {
|
||||||
|
root := mustResolveRoot()
|
||||||
|
|
||||||
|
f, err := sdlc.LoadFeature(root, args[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
|
printJSON(f)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Feature: %s\n", f.Slug)
|
||||||
|
fmt.Printf(" Title: %s\n", f.Title)
|
||||||
|
fmt.Printf(" Phase: %s\n", f.Phase)
|
||||||
|
fmt.Printf(" Created: %s\n", f.Created.Format("2006-01-02 15:04:05"))
|
||||||
|
if f.Branch != "" {
|
||||||
|
fmt.Printf(" Branch: %s\n", f.Branch)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
fmt.Println("Artifacts:")
|
||||||
|
for _, at := range sdlc.ValidArtifactTypes {
|
||||||
|
art := f.GetArtifact(at)
|
||||||
|
if art == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Printf(" %-12s %s\n", at, art.Status)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
if len(f.Tasks) > 0 {
|
||||||
|
summary := sdlc.SummarizeTasks(f.Tasks)
|
||||||
|
fmt.Printf("Tasks: %d/%d complete", summary.Completed, summary.Total)
|
||||||
|
if summary.InProgress > 0 {
|
||||||
|
fmt.Printf(", %d in-progress", summary.InProgress)
|
||||||
|
}
|
||||||
|
if summary.Blocked > 0 {
|
||||||
|
fmt.Printf(", %d blocked", summary.Blocked)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.IsBlocked() {
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Blockers:")
|
||||||
|
for _, b := range f.Blockers {
|
||||||
|
fmt.Printf(" - %s\n", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var featureStatusCmd = &cobra.Command{
|
||||||
|
Use: "status <slug>",
|
||||||
|
Short: "Show feature phase and progress",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(_ *cobra.Command, args []string) error {
|
||||||
|
root := mustResolveRoot()
|
||||||
|
|
||||||
|
f, err := sdlc.LoadFeature(root, args[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
|
result := map[string]any{
|
||||||
|
"slug": f.Slug,
|
||||||
|
"phase": f.Phase,
|
||||||
|
"blocked": f.IsBlocked(),
|
||||||
|
"tasks": sdlc.SummarizeTasks(f.Tasks),
|
||||||
|
"blockers": f.Blockers,
|
||||||
|
}
|
||||||
|
printJSON(result)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Feature: %s\n", f.Slug)
|
||||||
|
fmt.Printf("Phase: %s\n", f.Phase)
|
||||||
|
if f.IsBlocked() {
|
||||||
|
fmt.Println("Status: BLOCKED")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(f.Tasks) > 0 {
|
||||||
|
s := sdlc.SummarizeTasks(f.Tasks)
|
||||||
|
fmt.Printf("Tasks: %d/%d complete\n", s.Completed, s.Total)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var featureTransitionCmd = &cobra.Command{
|
||||||
|
Use: "transition <slug> <phase>",
|
||||||
|
Short: "Manually transition feature to a phase",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(_ *cobra.Command, args []string) error {
|
||||||
|
root := mustResolveRoot()
|
||||||
|
slug, phase := args[0], sdlc.FeaturePhase(args[1])
|
||||||
|
|
||||||
|
f, err := sdlc.LoadFeature(root, slug)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := sdlc.LoadConfig(root)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := f.CanTransitionTo(phase, cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
from := f.Phase
|
||||||
|
if err := f.Transition(phase); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := f.Save(root); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update state
|
||||||
|
state, err := sdlc.LoadState(root)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
state.UpdateActiveFeature(slug, phase, f.Branch)
|
||||||
|
state.RecordAction("TRANSITION", slug, "cli")
|
||||||
|
if err := state.Save(root); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
|
printJSON(map[string]string{
|
||||||
|
"slug": slug,
|
||||||
|
"from": string(from),
|
||||||
|
"to": string(phase),
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Transitioned %s: %s -> %s\n", slug, from, phase)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var featureBlockCmd = &cobra.Command{
|
||||||
|
Use: "block <slug> <reason>",
|
||||||
|
Short: "Mark feature as blocked",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(_ *cobra.Command, args []string) error {
|
||||||
|
root := mustResolveRoot()
|
||||||
|
slug, reason := args[0], args[1]
|
||||||
|
|
||||||
|
f, err := sdlc.LoadFeature(root, slug)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
f.AddBlocker(reason)
|
||||||
|
if err := f.Save(root); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
|
printJSON(map[string]string{"slug": slug, "blocker": reason})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Blocked %s: %s\n", slug, reason)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var featureUnblockCmd = &cobra.Command{
|
||||||
|
Use: "unblock <slug>",
|
||||||
|
Short: "Remove all blockers from feature",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(_ *cobra.Command, args []string) error {
|
||||||
|
root := mustResolveRoot()
|
||||||
|
slug := args[0]
|
||||||
|
|
||||||
|
f, err := sdlc.LoadFeature(root, slug)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
f.ClearBlockers()
|
||||||
|
if err := f.Save(root); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
|
printJSON(map[string]string{"slug": slug, "status": "unblocked"})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Unblocked %s\n", slug)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
featureCreateCmd.Flags().StringVar(&featureTitle, "title", "", "feature title")
|
||||||
|
featureCmd.AddCommand(
|
||||||
|
featureCreateCmd,
|
||||||
|
featureListCmd,
|
||||||
|
featureShowCmd,
|
||||||
|
featureStatusCmd,
|
||||||
|
featureTransitionCmd,
|
||||||
|
featureBlockCmd,
|
||||||
|
featureUnblockCmd,
|
||||||
|
)
|
||||||
|
rootCmd.AddCommand(featureCmd)
|
||||||
|
}
|
||||||
54
cmd/sdlc/cmd_init.go
Normal file
54
cmd/sdlc/cmd_init.go
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/orchard9/rdev/internal/sdlc"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var initProjectName string
|
||||||
|
|
||||||
|
var initCmd = &cobra.Command{
|
||||||
|
Use: "init",
|
||||||
|
Short: "Initialize .sdlc/ directory structure",
|
||||||
|
RunE: func(_ *cobra.Command, _ []string) error {
|
||||||
|
root := mustResolveRoot()
|
||||||
|
|
||||||
|
name := initProjectName
|
||||||
|
if name == "" {
|
||||||
|
name = filepath.Base(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sdlc.Init(root, name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
|
printJSON(map[string]string{
|
||||||
|
"status": "initialized",
|
||||||
|
"root": root,
|
||||||
|
"project": name,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Initialized .sdlc/ structure")
|
||||||
|
fmt.Printf(" Root: %s\n", root)
|
||||||
|
fmt.Printf(" Project: %s\n", name)
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Created directories:")
|
||||||
|
for _, dir := range sdlc.SubDirs() {
|
||||||
|
fmt.Printf(" .sdlc/%s/\n", dir)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Next: sdlc feature create <slug> --title \"Feature Name\"")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
initCmd.Flags().StringVar(&initProjectName, "name", "", "project name (default: directory name)")
|
||||||
|
rootCmd.AddCommand(initCmd)
|
||||||
|
}
|
||||||
137
cmd/sdlc/cmd_next.go
Normal file
137
cmd/sdlc/cmd_next.go
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/orchard9/rdev/internal/sdlc"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
nextForFeature string
|
||||||
|
)
|
||||||
|
|
||||||
|
var nextCmd = &cobra.Command{
|
||||||
|
Use: "next",
|
||||||
|
Short: "Run classifier and show next required action",
|
||||||
|
RunE: func(_ *cobra.Command, _ []string) error {
|
||||||
|
root := mustResolveRoot()
|
||||||
|
|
||||||
|
state, err := sdlc.LoadState(root)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := sdlc.LoadConfig(root)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
classifier := sdlc.NewClassifier()
|
||||||
|
|
||||||
|
// If a specific feature is requested
|
||||||
|
if nextForFeature != "" {
|
||||||
|
return classifyFeature(root, state, cfg, classifier, nextForFeature)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Classify all active features, return first actionable
|
||||||
|
if len(state.ActiveWork.Features) == 0 {
|
||||||
|
if jsonOutput {
|
||||||
|
printJSON(map[string]string{"action": "IDLE", "message": "No active features"})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Println("No active features. Create one: sdlc feature create <slug>")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, af := range state.ActiveWork.Features {
|
||||||
|
f, err := sdlc.LoadFeature(root, af.Slug)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
cl := classifier.Classify(&sdlc.EvalContext{
|
||||||
|
State: state,
|
||||||
|
Feature: f,
|
||||||
|
Config: cfg,
|
||||||
|
Root: root,
|
||||||
|
})
|
||||||
|
|
||||||
|
if cl.Action != sdlc.ActionIdle {
|
||||||
|
return printClassification(cl, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
|
printJSON(map[string]string{"action": "IDLE", "message": "No actionable work found"})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("No actionable work found across active features.")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifyFeature(root string, state *sdlc.State, cfg *sdlc.Config, classifier *sdlc.Classifier, slug string) error {
|
||||||
|
f, err := sdlc.LoadFeature(root, slug)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cl := classifier.Classify(&sdlc.EvalContext{
|
||||||
|
State: state,
|
||||||
|
Feature: f,
|
||||||
|
Config: cfg,
|
||||||
|
Root: root,
|
||||||
|
})
|
||||||
|
|
||||||
|
return printClassification(cl, f)
|
||||||
|
}
|
||||||
|
|
||||||
|
func printClassification(cl *sdlc.Classification, f *sdlc.Feature) error {
|
||||||
|
if jsonOutput {
|
||||||
|
printJSON(cl)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Feature: %s\n", cl.Feature)
|
||||||
|
fmt.Printf("Phase: %s\n", cl.CurrentPhase)
|
||||||
|
|
||||||
|
if len(f.Tasks) > 0 {
|
||||||
|
s := sdlc.SummarizeTasks(f.Tasks)
|
||||||
|
fmt.Printf("Tasks: %d/%d complete", s.Completed, s.Total)
|
||||||
|
if s.InProgress > 0 {
|
||||||
|
fmt.Printf(", %d in-progress", s.InProgress)
|
||||||
|
}
|
||||||
|
if s.Pending > 0 {
|
||||||
|
fmt.Printf(", %d pending", s.Pending)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
fmt.Printf("NEXT ACTION: %s\n", cl.Action)
|
||||||
|
if cl.Message != "" {
|
||||||
|
fmt.Printf("Message: %s\n", cl.Message)
|
||||||
|
}
|
||||||
|
if cl.NextCommand != "" {
|
||||||
|
fmt.Printf("Command: %s\n", cl.NextCommand)
|
||||||
|
}
|
||||||
|
if cl.OutputPath != "" {
|
||||||
|
fmt.Printf("Output: %s\n", cl.OutputPath)
|
||||||
|
}
|
||||||
|
if cl.TransitionTo != "" {
|
||||||
|
fmt.Printf("Transition: -> %s\n", cl.TransitionTo)
|
||||||
|
}
|
||||||
|
if cl.TaskID != "" {
|
||||||
|
fmt.Printf("Task: %s\n", cl.TaskID)
|
||||||
|
}
|
||||||
|
fmt.Printf("Rule: %s\n", cl.RuleMatched)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
nextCmd.Flags().StringVar(&nextForFeature, "for", "", "classify specific feature")
|
||||||
|
rootCmd.AddCommand(nextCmd)
|
||||||
|
}
|
||||||
202
cmd/sdlc/cmd_query.go
Normal file
202
cmd/sdlc/cmd_query.go
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/orchard9/rdev/internal/sdlc"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var queryCmd = &cobra.Command{
|
||||||
|
Use: "query",
|
||||||
|
Short: "Query SDLC state",
|
||||||
|
}
|
||||||
|
|
||||||
|
var queryBlockedCmd = &cobra.Command{
|
||||||
|
Use: "blocked",
|
||||||
|
Short: "List all blocked items",
|
||||||
|
RunE: func(_ *cobra.Command, _ []string) error {
|
||||||
|
root := mustResolveRoot()
|
||||||
|
|
||||||
|
features, err := sdlc.ListFeatures(root)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type blockedInfo struct {
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Phase string `json:"phase"`
|
||||||
|
Blockers []string `json:"blockers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var blocked []blockedInfo
|
||||||
|
for _, f := range features {
|
||||||
|
if f.IsBlocked() {
|
||||||
|
blocked = append(blocked, blockedInfo{
|
||||||
|
Slug: f.Slug,
|
||||||
|
Phase: string(f.Phase),
|
||||||
|
Blockers: f.Blockers,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
|
printJSON(blocked)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(blocked) == 0 {
|
||||||
|
fmt.Println("No blocked items.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Blocked Items:")
|
||||||
|
for _, b := range blocked {
|
||||||
|
fmt.Printf(" %s [%s]:\n", b.Slug, b.Phase)
|
||||||
|
for _, reason := range b.Blockers {
|
||||||
|
fmt.Printf(" - %s\n", reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var queryReadyCmd = &cobra.Command{
|
||||||
|
Use: "ready",
|
||||||
|
Short: "List items ready for work",
|
||||||
|
RunE: func(_ *cobra.Command, _ []string) error {
|
||||||
|
root := mustResolveRoot()
|
||||||
|
|
||||||
|
state, err := sdlc.LoadState(root)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := sdlc.LoadConfig(root)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
features, err := sdlc.ListFeatures(root)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
classifier := sdlc.NewClassifier()
|
||||||
|
|
||||||
|
type readyInfo struct {
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Phase string `json:"phase"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var ready []readyInfo
|
||||||
|
for _, f := range features {
|
||||||
|
if f.IsBlocked() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cl := classifier.Classify(&sdlc.EvalContext{
|
||||||
|
State: state,
|
||||||
|
Feature: f,
|
||||||
|
Config: cfg,
|
||||||
|
Root: root,
|
||||||
|
})
|
||||||
|
if cl.Action != sdlc.ActionIdle && cl.Action != sdlc.ActionBlocked && cl.Action != sdlc.ActionAwaitApproval {
|
||||||
|
ready = append(ready, readyInfo{
|
||||||
|
Slug: f.Slug,
|
||||||
|
Phase: string(f.Phase),
|
||||||
|
Action: string(cl.Action),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
|
printJSON(ready)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ready) == 0 {
|
||||||
|
fmt.Println("No items ready for work.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Ready for Work:")
|
||||||
|
for _, r := range ready {
|
||||||
|
fmt.Printf(" %-20s [%-15s] -> %s\n", r.Slug, r.Phase, r.Action)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var queryNeedsApprovalCmd = &cobra.Command{
|
||||||
|
Use: "needs-approval",
|
||||||
|
Short: "List items awaiting approval",
|
||||||
|
RunE: func(_ *cobra.Command, _ []string) error {
|
||||||
|
root := mustResolveRoot()
|
||||||
|
|
||||||
|
state, err := sdlc.LoadState(root)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := sdlc.LoadConfig(root)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
features, err := sdlc.ListFeatures(root)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
classifier := sdlc.NewClassifier()
|
||||||
|
|
||||||
|
type approvalInfo struct {
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Phase string `json:"phase"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var pending []approvalInfo
|
||||||
|
for _, f := range features {
|
||||||
|
cl := classifier.Classify(&sdlc.EvalContext{
|
||||||
|
State: state,
|
||||||
|
Feature: f,
|
||||||
|
Config: cfg,
|
||||||
|
Root: root,
|
||||||
|
})
|
||||||
|
if cl.Action == sdlc.ActionAwaitApproval {
|
||||||
|
pending = append(pending, approvalInfo{
|
||||||
|
Slug: f.Slug,
|
||||||
|
Phase: string(f.Phase),
|
||||||
|
Message: cl.Message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
|
printJSON(pending)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(pending) == 0 {
|
||||||
|
fmt.Println("No items awaiting approval.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Awaiting Approval:")
|
||||||
|
for _, p := range pending {
|
||||||
|
fmt.Printf(" %-20s [%-15s] %s\n", p.Slug, p.Phase, p.Message)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
queryCmd.AddCommand(
|
||||||
|
queryBlockedCmd,
|
||||||
|
queryReadyCmd,
|
||||||
|
queryNeedsApprovalCmd,
|
||||||
|
)
|
||||||
|
rootCmd.AddCommand(queryCmd)
|
||||||
|
}
|
||||||
66
cmd/sdlc/cmd_state.go
Normal file
66
cmd/sdlc/cmd_state.go
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/orchard9/rdev/internal/sdlc"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var stateCmd = &cobra.Command{
|
||||||
|
Use: "state",
|
||||||
|
Short: "Show current SDLC state",
|
||||||
|
RunE: func(_ *cobra.Command, _ []string) error {
|
||||||
|
root := mustResolveRoot()
|
||||||
|
|
||||||
|
state, err := sdlc.LoadState(root)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
|
printJSON(state)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("SDLC State (v%d)\n", state.Version)
|
||||||
|
fmt.Printf(" Project: %s\n", state.Project.Name)
|
||||||
|
if state.Project.CurrentRoadmap != "" {
|
||||||
|
fmt.Printf(" Roadmap: %s\n", state.Project.CurrentRoadmap)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
if len(state.ActiveWork.Features) > 0 {
|
||||||
|
fmt.Println("Active Features:")
|
||||||
|
for _, f := range state.ActiveWork.Features {
|
||||||
|
branch := ""
|
||||||
|
if f.Branch != "" {
|
||||||
|
branch = fmt.Sprintf(" (%s)", f.Branch)
|
||||||
|
}
|
||||||
|
fmt.Printf(" - %s [%s]%s\n", f.Slug, f.Phase, branch)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(state.Blocked) > 0 {
|
||||||
|
fmt.Println("Blocked Items:")
|
||||||
|
for _, b := range state.Blocked {
|
||||||
|
fmt.Printf(" - %s/%s: %s\n", b.Type, b.Slug, b.Reason)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.LastAction != "" {
|
||||||
|
fmt.Printf("Last Action: %s by %s\n", state.LastAction, state.LastActor)
|
||||||
|
}
|
||||||
|
if state.LastUpdated != nil {
|
||||||
|
fmt.Printf("Last Updated: %s\n", state.LastUpdated.Format("2006-01-02 15:04:05"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(stateCmd)
|
||||||
|
}
|
||||||
207
cmd/sdlc/cmd_task.go
Normal file
207
cmd/sdlc/cmd_task.go
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/orchard9/rdev/internal/sdlc"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var taskCmd = &cobra.Command{
|
||||||
|
Use: "task",
|
||||||
|
Short: "Manage feature tasks",
|
||||||
|
}
|
||||||
|
|
||||||
|
var taskListCmd = &cobra.Command{
|
||||||
|
Use: "list <feature>",
|
||||||
|
Short: "List tasks for a feature",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(_ *cobra.Command, args []string) error {
|
||||||
|
root := mustResolveRoot()
|
||||||
|
|
||||||
|
f, err := sdlc.LoadFeature(root, args[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
|
printJSON(f.Tasks)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(f.Tasks) == 0 {
|
||||||
|
fmt.Println("No tasks defined.")
|
||||||
|
fmt.Printf("Add one: sdlc task add %s \"Task title\"\n", args[0])
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := sdlc.SummarizeTasks(f.Tasks)
|
||||||
|
fmt.Printf("Tasks for %s (%d/%d complete):\n", f.Slug, summary.Completed, summary.Total)
|
||||||
|
for _, t := range f.Tasks {
|
||||||
|
icon := "○"
|
||||||
|
switch t.Status {
|
||||||
|
case sdlc.TaskComplete:
|
||||||
|
icon = "✓"
|
||||||
|
case sdlc.TaskInProgress:
|
||||||
|
icon = "→"
|
||||||
|
case sdlc.TaskBlocked:
|
||||||
|
icon = "✗"
|
||||||
|
}
|
||||||
|
fmt.Printf(" %s %s: %s [%s]\n", icon, t.ID, t.Title, t.Status)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var taskStartCmd = &cobra.Command{
|
||||||
|
Use: "start <feature> <task-id>",
|
||||||
|
Short: "Mark a task as in-progress",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(_ *cobra.Command, args []string) error {
|
||||||
|
root := mustResolveRoot()
|
||||||
|
slug, taskID := args[0], args[1]
|
||||||
|
|
||||||
|
f, err := sdlc.LoadFeature(root, slug)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
f.Tasks, err = sdlc.StartTask(f.Tasks, taskID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f.UpdateTaskSummary()
|
||||||
|
|
||||||
|
if err := f.Save(root); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
|
printJSON(map[string]string{"feature": slug, "task": taskID, "status": "in_progress"})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Started: %s/%s\n", slug, taskID)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var taskCompleteCmd = &cobra.Command{
|
||||||
|
Use: "complete <feature> <task-id>",
|
||||||
|
Short: "Mark a task as complete",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(_ *cobra.Command, args []string) error {
|
||||||
|
root := mustResolveRoot()
|
||||||
|
slug, taskID := args[0], args[1]
|
||||||
|
|
||||||
|
f, err := sdlc.LoadFeature(root, slug)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
f.Tasks, err = sdlc.CompleteTask(f.Tasks, taskID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f.UpdateTaskSummary()
|
||||||
|
|
||||||
|
if err := f.Save(root); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record in state
|
||||||
|
state, err := sdlc.LoadState(root)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
state.RecordAction("COMPLETE_TASK", slug, "cli")
|
||||||
|
if err := state.Save(root); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
|
printJSON(map[string]string{"feature": slug, "task": taskID, "status": "complete"})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
s := sdlc.SummarizeTasks(f.Tasks)
|
||||||
|
fmt.Printf("Completed: %s/%s (%d/%d tasks done)\n", slug, taskID, s.Completed, s.Total)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var taskBlockCmd = &cobra.Command{
|
||||||
|
Use: "block <feature> <task-id>",
|
||||||
|
Short: "Mark a task as blocked",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(_ *cobra.Command, args []string) error {
|
||||||
|
root := mustResolveRoot()
|
||||||
|
slug, taskID := args[0], args[1]
|
||||||
|
|
||||||
|
f, err := sdlc.LoadFeature(root, slug)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
f.Tasks, err = sdlc.BlockTask(f.Tasks, taskID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f.UpdateTaskSummary()
|
||||||
|
|
||||||
|
if err := f.Save(root); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
|
printJSON(map[string]string{"feature": slug, "task": taskID, "status": "blocked"})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Blocked: %s/%s\n", slug, taskID)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var taskAddCmd = &cobra.Command{
|
||||||
|
Use: "add <feature> <title>",
|
||||||
|
Short: "Add a new task",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(_ *cobra.Command, args []string) error {
|
||||||
|
root := mustResolveRoot()
|
||||||
|
slug, title := args[0], args[1]
|
||||||
|
|
||||||
|
f, err := sdlc.LoadFeature(root, slug)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
f.Tasks = sdlc.AddTask(f.Tasks, title)
|
||||||
|
f.UpdateTaskSummary()
|
||||||
|
|
||||||
|
if err := f.Save(root); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
newTask := f.Tasks[len(f.Tasks)-1]
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
|
printJSON(newTask)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Added: %s/%s - %s\n", slug, newTask.ID, title)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
taskCmd.AddCommand(
|
||||||
|
taskListCmd,
|
||||||
|
taskStartCmd,
|
||||||
|
taskCompleteCmd,
|
||||||
|
taskBlockCmd,
|
||||||
|
taskAddCmd,
|
||||||
|
)
|
||||||
|
rootCmd.AddCommand(taskCmd)
|
||||||
|
}
|
||||||
13
cmd/sdlc/main.go
Normal file
13
cmd/sdlc/main.go
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
81
cmd/sdlc/root.go
Normal file
81
cmd/sdlc/root.go
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/orchard9/rdev/internal/sdlc"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
rootDir string
|
||||||
|
jsonOutput bool
|
||||||
|
)
|
||||||
|
|
||||||
|
var rootCmd = &cobra.Command{
|
||||||
|
Use: "sdlc",
|
||||||
|
Short: "Deterministic SDLC orchestration tool",
|
||||||
|
Long: "Manage the software development lifecycle with deterministic state, artifacts, and classification.",
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.PersistentFlags().StringVar(&rootDir, "root", "", "project root (default: auto-detect)")
|
||||||
|
rootCmd.PersistentFlags().BoolVar(&jsonOutput, "json", false, "output as JSON")
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveRoot finds the project root by looking for .sdlc/ or .git/ walking up.
|
||||||
|
func resolveRoot() (string, error) {
|
||||||
|
if rootDir != "" {
|
||||||
|
abs, err := filepath.Abs(rootDir)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("resolve root: %w", err)
|
||||||
|
}
|
||||||
|
return abs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
dir, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("get working directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
if sdlc.IsInitialized(dir) {
|
||||||
|
return dir, nil
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
|
||||||
|
return dir, nil
|
||||||
|
}
|
||||||
|
parent := filepath.Dir(dir)
|
||||||
|
if parent == dir {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
dir = parent
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to cwd
|
||||||
|
cwd, _ := os.Getwd()
|
||||||
|
return cwd, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustResolveRoot resolves root or exits.
|
||||||
|
func mustResolveRoot() string {
|
||||||
|
root, err := resolveRoot()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
// printJSON marshals v as indented JSON and prints to stdout.
|
||||||
|
func printJSON(v any) {
|
||||||
|
data, err := json.MarshalIndent(v, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error marshaling JSON: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Println(string(data))
|
||||||
|
}
|
||||||
@ -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
|
## Questions to Resolve
|
||||||
|
|
||||||
1. **Claudebox scaling strategy?**
|
1. **Claudebox scaling strategy?**
|
||||||
|
|||||||
@ -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
|
## Related
|
||||||
|
|
||||||
- [Composable App Cookbook](./composable-app.md) - Creating projects with components
|
- [Composable App Cookbook](./composable-app.md) - Creating projects with components
|
||||||
|
|||||||
@ -97,6 +97,30 @@ verify_chassis_patterns() {
|
|||||||
else
|
else
|
||||||
print_warning "pkg/auth/ not found"
|
print_warning "pkg/auth/ not found"
|
||||||
fi
|
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
|
# Verify design system packages
|
||||||
@ -187,6 +211,30 @@ verify_service_patterns() {
|
|||||||
else
|
else
|
||||||
print_warning "routes.go not found"
|
print_warning "routes.go not found"
|
||||||
fi
|
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
|
# Verify app-nextjs component
|
||||||
|
|||||||
4
go.mod
4
go.mod
@ -11,12 +11,14 @@ require (
|
|||||||
github.com/lib/pq v1.10.9
|
github.com/lib/pq v1.10.9
|
||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/prometheus/client_golang v1.23.2
|
||||||
github.com/redis/go-redis/v9 v9.17.3
|
github.com/redis/go-redis/v9 v9.17.3
|
||||||
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
go.opentelemetry.io/otel v1.39.0
|
go.opentelemetry.io/otel v1.39.0
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc 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/sdk v1.39.0
|
||||||
go.opentelemetry.io/otel/trace v1.39.0
|
go.opentelemetry.io/otel/trace v1.39.0
|
||||||
go.woodpecker-ci.org/woodpecker/v3 v3.13.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/api v0.35.0
|
||||||
k8s.io/apimachinery v0.35.0
|
k8s.io/apimachinery v0.35.0
|
||||||
k8s.io/client-go 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/google/gnostic-models v0.7.0 // indirect
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
|
||||||
github.com/hashicorp/go-version v1.7.0 // 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/josharian/intern v1.0.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/mailru/easyjson v0.7.7 // indirect
|
github.com/mailru/easyjson v0.7.7 // indirect
|
||||||
@ -72,7 +75,6 @@ require (
|
|||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
|
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
|
||||||
gopkg.in/inf.v0 v0.9.1 // 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/klog/v2 v2.130.1 // indirect
|
||||||
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect
|
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect
|
||||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect
|
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect
|
||||||
|
|||||||
6
go.sum
6
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/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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
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.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.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
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/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 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
|
||||||
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
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 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
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/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 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
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 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
|||||||
288
internal/adapter/kubernetes/sdlc_executor.go
Normal file
288
internal/adapter/kubernetes/sdlc_executor.go
Normal file
@ -0,0 +1,288 @@
|
|||||||
|
package kubernetes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/orchard9/rdev/internal/port"
|
||||||
|
"github.com/orchard9/rdev/internal/sdlc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SDLCExecutor runs sdlc CLI commands inside pods via kubectl exec.
|
||||||
|
type SDLCExecutor struct {
|
||||||
|
namespace string
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// SDLCExecutorConfig configures the SDLC executor.
|
||||||
|
type SDLCExecutorConfig struct {
|
||||||
|
Namespace string
|
||||||
|
Logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSDLCExecutor creates a new SDLC executor.
|
||||||
|
func NewSDLCExecutor(cfg SDLCExecutorConfig) *SDLCExecutor {
|
||||||
|
logger := cfg.Logger
|
||||||
|
if logger == nil {
|
||||||
|
logger = slog.Default()
|
||||||
|
}
|
||||||
|
return &SDLCExecutor{
|
||||||
|
namespace: cfg.Namespace,
|
||||||
|
logger: logger.With("component", "sdlc-executor"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// execSDLC runs a sdlc CLI command in the pod and returns stdout bytes.
|
||||||
|
// All commands include --json for machine-readable output.
|
||||||
|
func (e *SDLCExecutor) execSDLC(ctx context.Context, podName string, args ...string) ([]byte, error) {
|
||||||
|
kubectlArgs := []string{"exec", "-n", e.namespace, podName, "--", "sdlc"}
|
||||||
|
kubectlArgs = append(kubectlArgs, args...)
|
||||||
|
kubectlArgs = append(kubectlArgs, "--json")
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "kubectl", kubectlArgs...)
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return nil, e.mapExecError(stderr.String(), err)
|
||||||
|
}
|
||||||
|
return stdout.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// execSDLCNoOutput runs a sdlc CLI command that produces no meaningful stdout.
|
||||||
|
func (e *SDLCExecutor) execSDLCNoOutput(ctx context.Context, podName string, args ...string) error {
|
||||||
|
_, err := e.execSDLC(ctx, podName, args...)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// mapExecError maps stderr text from the sdlc CLI to sentinel errors.
|
||||||
|
func (e *SDLCExecutor) mapExecError(stderr string, execErr error) error {
|
||||||
|
stderr = strings.TrimSpace(stderr)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case strings.Contains(stderr, "sdlc not initialized"):
|
||||||
|
return sdlc.ErrNotInitialized
|
||||||
|
case strings.Contains(stderr, "feature not found"):
|
||||||
|
return sdlc.ErrFeatureNotFound
|
||||||
|
case strings.Contains(stderr, "feature already exists"):
|
||||||
|
return sdlc.ErrFeatureExists
|
||||||
|
case strings.Contains(stderr, "invalid phase transition"):
|
||||||
|
return sdlc.ErrInvalidTransition
|
||||||
|
case strings.Contains(stderr, "invalid phase"):
|
||||||
|
return sdlc.ErrInvalidPhase
|
||||||
|
case strings.Contains(stderr, "task not found"):
|
||||||
|
return sdlc.ErrTaskNotFound
|
||||||
|
case strings.Contains(stderr, "artifact not found"):
|
||||||
|
return sdlc.ErrArtifactNotFound
|
||||||
|
case strings.Contains(stderr, "invalid slug"):
|
||||||
|
return sdlc.ErrInvalidSlug
|
||||||
|
case strings.Contains(stderr, "invalid artifact"):
|
||||||
|
return sdlc.ErrInvalidArtifact
|
||||||
|
default:
|
||||||
|
if stderr != "" {
|
||||||
|
return fmt.Errorf("sdlc exec: %s: %w", stderr, execErr)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("sdlc exec: %w", execErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetState returns the global SDLC state for a project pod.
|
||||||
|
func (e *SDLCExecutor) GetState(ctx context.Context, podName string) (*sdlc.State, error) {
|
||||||
|
out, err := e.execSDLC(ctx, podName, "state")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var state sdlc.State
|
||||||
|
if err := json.Unmarshal(out, &state); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse sdlc state: %w", err)
|
||||||
|
}
|
||||||
|
return &state, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNext returns the classifier's recommendation for the next action.
|
||||||
|
func (e *SDLCExecutor) GetNext(ctx context.Context, podName, feature string) (*sdlc.Classification, error) {
|
||||||
|
args := []string{"next"}
|
||||||
|
if feature != "" {
|
||||||
|
args = append(args, "--feature", feature)
|
||||||
|
}
|
||||||
|
out, err := e.execSDLC(ctx, podName, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var cl sdlc.Classification
|
||||||
|
if err := json.Unmarshal(out, &cl); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse sdlc classification: %w", err)
|
||||||
|
}
|
||||||
|
return &cl, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListFeatures returns all features in the project.
|
||||||
|
func (e *SDLCExecutor) ListFeatures(ctx context.Context, podName string) ([]*sdlc.Feature, error) {
|
||||||
|
out, err := e.execSDLC(ctx, podName, "feature", "list")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var features []*sdlc.Feature
|
||||||
|
if err := json.Unmarshal(out, &features); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse sdlc features: %w", err)
|
||||||
|
}
|
||||||
|
return features, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFeature returns a single feature by slug.
|
||||||
|
func (e *SDLCExecutor) GetFeature(ctx context.Context, podName, slug string) (*sdlc.Feature, error) {
|
||||||
|
out, err := e.execSDLC(ctx, podName, "feature", "show", slug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var f sdlc.Feature
|
||||||
|
if err := json.Unmarshal(out, &f); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse sdlc feature: %w", err)
|
||||||
|
}
|
||||||
|
return &f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateFeature creates a new feature with the given slug and title.
|
||||||
|
func (e *SDLCExecutor) CreateFeature(ctx context.Context, podName, slug, title string) (*sdlc.Feature, error) {
|
||||||
|
out, err := e.execSDLC(ctx, podName, "feature", "create", slug, "--title", title)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var f sdlc.Feature
|
||||||
|
if err := json.Unmarshal(out, &f); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse sdlc feature: %w", err)
|
||||||
|
}
|
||||||
|
return &f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TransitionFeature moves a feature to the specified phase.
|
||||||
|
func (e *SDLCExecutor) TransitionFeature(ctx context.Context, podName, slug string, phase sdlc.FeaturePhase) error {
|
||||||
|
return e.execSDLCNoOutput(ctx, podName, "feature", "transition", slug, string(phase))
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockFeature adds a blocker reason to a feature.
|
||||||
|
func (e *SDLCExecutor) BlockFeature(ctx context.Context, podName, slug, reason string) error {
|
||||||
|
return e.execSDLCNoOutput(ctx, podName, "feature", "block", slug, "--reason", reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnblockFeature removes all blockers from a feature.
|
||||||
|
func (e *SDLCExecutor) UnblockFeature(ctx context.Context, podName, slug string) error {
|
||||||
|
return e.execSDLCNoOutput(ctx, podName, "feature", "unblock", slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteFeature removes a feature entirely.
|
||||||
|
func (e *SDLCExecutor) DeleteFeature(ctx context.Context, podName, slug string) error {
|
||||||
|
return e.execSDLCNoOutput(ctx, podName, "feature", "delete", slug, "--force")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetArtifactStatus returns artifact statuses for a feature.
|
||||||
|
func (e *SDLCExecutor) GetArtifactStatus(ctx context.Context, podName, slug string) (map[sdlc.ArtifactType]*sdlc.Artifact, error) {
|
||||||
|
out, err := e.execSDLC(ctx, podName, "artifact", "status", slug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var artifacts map[sdlc.ArtifactType]*sdlc.Artifact
|
||||||
|
if err := json.Unmarshal(out, &artifacts); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse sdlc artifacts: %w", err)
|
||||||
|
}
|
||||||
|
return artifacts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApproveArtifact approves a feature artifact.
|
||||||
|
func (e *SDLCExecutor) ApproveArtifact(ctx context.Context, podName, slug string, artType sdlc.ArtifactType) error {
|
||||||
|
return e.execSDLCNoOutput(ctx, podName, "artifact", "approve", slug, string(artType))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RejectArtifact rejects a feature artifact.
|
||||||
|
func (e *SDLCExecutor) RejectArtifact(ctx context.Context, podName, slug string, artType sdlc.ArtifactType) error {
|
||||||
|
return e.execSDLCNoOutput(ctx, podName, "artifact", "reject", slug, string(artType))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTasks returns all tasks for a feature.
|
||||||
|
func (e *SDLCExecutor) ListTasks(ctx context.Context, podName, slug string) ([]sdlc.Task, error) {
|
||||||
|
out, err := e.execSDLC(ctx, podName, "task", "list", slug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var tasks []sdlc.Task
|
||||||
|
if err := json.Unmarshal(out, &tasks); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse sdlc tasks: %w", err)
|
||||||
|
}
|
||||||
|
return tasks, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddTask adds a new task to a feature.
|
||||||
|
func (e *SDLCExecutor) AddTask(ctx context.Context, podName, slug, title string) (*sdlc.Task, error) {
|
||||||
|
out, err := e.execSDLC(ctx, podName, "task", "add", slug, "--title", title)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var t sdlc.Task
|
||||||
|
if err := json.Unmarshal(out, &t); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse sdlc task: %w", err)
|
||||||
|
}
|
||||||
|
return &t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartTask marks a task as in-progress.
|
||||||
|
func (e *SDLCExecutor) StartTask(ctx context.Context, podName, slug, taskID string) error {
|
||||||
|
return e.execSDLCNoOutput(ctx, podName, "task", "start", slug, taskID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompleteTask marks a task as complete.
|
||||||
|
func (e *SDLCExecutor) CompleteTask(ctx context.Context, podName, slug, taskID string) error {
|
||||||
|
return e.execSDLCNoOutput(ctx, podName, "task", "complete", slug, taskID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockTask marks a task as blocked.
|
||||||
|
func (e *SDLCExecutor) BlockTask(ctx context.Context, podName, slug, taskID string) error {
|
||||||
|
return e.execSDLCNoOutput(ctx, podName, "task", "block", slug, taskID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryBlocked returns all blocked features.
|
||||||
|
func (e *SDLCExecutor) QueryBlocked(ctx context.Context, podName string) ([]port.BlockedInfo, error) {
|
||||||
|
out, err := e.execSDLC(ctx, podName, "query", "blocked")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var blocked []port.BlockedInfo
|
||||||
|
if err := json.Unmarshal(out, &blocked); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse sdlc blocked query: %w", err)
|
||||||
|
}
|
||||||
|
return blocked, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryReady returns features ready for work.
|
||||||
|
func (e *SDLCExecutor) QueryReady(ctx context.Context, podName string) ([]port.ReadyInfo, error) {
|
||||||
|
out, err := e.execSDLC(ctx, podName, "query", "ready")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var ready []port.ReadyInfo
|
||||||
|
if err := json.Unmarshal(out, &ready); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse sdlc ready query: %w", err)
|
||||||
|
}
|
||||||
|
return ready, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryNeedsApproval returns features awaiting approval.
|
||||||
|
func (e *SDLCExecutor) QueryNeedsApproval(ctx context.Context, podName string) ([]port.ApprovalInfo, error) {
|
||||||
|
out, err := e.execSDLC(ctx, podName, "query", "needs-approval")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var pending []port.ApprovalInfo
|
||||||
|
if err := json.Unmarshal(out, &pending); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse sdlc approval query: %w", err)
|
||||||
|
}
|
||||||
|
return pending, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compile-time interface check.
|
||||||
|
var _ port.SDLCExecutor = (*SDLCExecutor)(nil)
|
||||||
114
internal/adapter/kubernetes/sdlc_executor_test.go
Normal file
114
internal/adapter/kubernetes/sdlc_executor_test.go
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
package kubernetes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/orchard9/rdev/internal/sdlc"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMapExecError(t *testing.T) {
|
||||||
|
exec := &SDLCExecutor{namespace: "test"}
|
||||||
|
baseErr := errors.New("exit status 1")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
stderr string
|
||||||
|
want error
|
||||||
|
wantMsg string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "not initialized",
|
||||||
|
stderr: "Error: sdlc not initialized: run 'sdlc init'",
|
||||||
|
want: sdlc.ErrNotInitialized,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "feature not found",
|
||||||
|
stderr: "Error: feature not found",
|
||||||
|
want: sdlc.ErrFeatureNotFound,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "feature already exists",
|
||||||
|
stderr: "Error: feature already exists",
|
||||||
|
want: sdlc.ErrFeatureExists,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid phase transition",
|
||||||
|
stderr: "Error: invalid phase transition: cannot move from draft to implementation (backward)",
|
||||||
|
want: sdlc.ErrInvalidTransition,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid phase",
|
||||||
|
stderr: "Error: invalid phase: xyz",
|
||||||
|
want: sdlc.ErrInvalidPhase,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "task not found",
|
||||||
|
stderr: "Error: task not found",
|
||||||
|
want: sdlc.ErrTaskNotFound,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "artifact not found",
|
||||||
|
stderr: "Error: artifact not found",
|
||||||
|
want: sdlc.ErrArtifactNotFound,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid slug",
|
||||||
|
stderr: "Error: invalid slug: must be lowercase alphanumeric with hyphens",
|
||||||
|
want: sdlc.ErrInvalidSlug,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid artifact type",
|
||||||
|
stderr: "Error: invalid artifact type: foobar",
|
||||||
|
want: sdlc.ErrInvalidArtifact,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown error with stderr",
|
||||||
|
stderr: "something unexpected happened",
|
||||||
|
wantMsg: "sdlc exec: something unexpected happened: exit status 1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown error without stderr",
|
||||||
|
stderr: "",
|
||||||
|
wantMsg: "sdlc exec: exit status 1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := exec.mapExecError(tt.stderr, baseErr)
|
||||||
|
if tt.want != nil {
|
||||||
|
if !errors.Is(got, tt.want) {
|
||||||
|
t.Errorf("mapExecError() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
} else if tt.wantMsg != "" {
|
||||||
|
if got.Error() != tt.wantMsg {
|
||||||
|
t.Errorf("mapExecError() message = %q, want %q", got.Error(), tt.wantMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMapExecError_WhitespaceHandling(t *testing.T) {
|
||||||
|
exec := &SDLCExecutor{namespace: "test"}
|
||||||
|
baseErr := errors.New("exit status 1")
|
||||||
|
|
||||||
|
// Stderr with leading/trailing whitespace
|
||||||
|
got := exec.mapExecError(" feature not found\n ", baseErr)
|
||||||
|
if !errors.Is(got, sdlc.ErrFeatureNotFound) {
|
||||||
|
t.Errorf("expected ErrFeatureNotFound, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewSDLCExecutor(t *testing.T) {
|
||||||
|
exec := NewSDLCExecutor(SDLCExecutorConfig{
|
||||||
|
Namespace: "rdev",
|
||||||
|
})
|
||||||
|
if exec.namespace != "rdev" {
|
||||||
|
t.Errorf("namespace = %q, want %q", exec.namespace, "rdev")
|
||||||
|
}
|
||||||
|
if exec.logger == nil {
|
||||||
|
t.Error("logger should not be nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -13,5 +13,9 @@ APP_DEBUG=true
|
|||||||
LOG_LEVEL=debug
|
LOG_LEVEL=debug
|
||||||
LOG_FORMAT=text
|
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 (if needed)
|
||||||
DATABASE_URL=postgres://dev:dev@localhost:5432/{{PROJECT_NAME}}?sslmode=disable
|
DATABASE_URL=postgres://dev:dev@localhost:5432/{{PROJECT_NAME}}?sslmode=disable
|
||||||
|
|||||||
@ -3,6 +3,9 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"{{GO_MODULE}}/pkg/app"
|
"{{GO_MODULE}}/pkg/app"
|
||||||
"{{GO_MODULE}}/pkg/httperror"
|
"{{GO_MODULE}}/pkg/httperror"
|
||||||
"{{GO_MODULE}}/pkg/httpresponse"
|
"{{GO_MODULE}}/pkg/httpresponse"
|
||||||
@ -25,34 +28,82 @@ type CreateRequest struct {
|
|||||||
Description string `json:"description" validate:"max=500"`
|
Description string `json:"description" validate:"max=500"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateResponse is the response for creating an example.
|
// UpdateRequest is the request body for updating an example.
|
||||||
type CreateResponse struct {
|
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"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
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.
|
// Get returns an example by ID.
|
||||||
// Demonstrates returning HTTPErrors for common error cases.
|
// Demonstrates returning HTTPErrors for common error cases.
|
||||||
func (h *Example) Get(w http.ResponseWriter, r *http.Request) error {
|
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
|
// Validate UUID format
|
||||||
// if item == nil {
|
if _, err := uuid.Parse(id); err != nil {
|
||||||
// return httperror.NotFoundf("example %s not found", id)
|
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
|
// Placeholder response
|
||||||
// if !canAccess(user, item) {
|
httpresponse.OK(w, r, ExampleResponse{
|
||||||
// return httperror.Forbidden("access denied")
|
ID: id,
|
||||||
// }
|
Name: "Example Item",
|
||||||
|
Description: "This is an example item",
|
||||||
// Success response
|
CreatedAt: "2024-01-15T10:30:00Z",
|
||||||
httpresponse.OK(w, r, map[string]any{
|
UpdatedAt: "2024-01-15T10:30:00Z",
|
||||||
"id": "example-123",
|
|
||||||
"name": "Example Item",
|
|
||||||
"description": "This is an example item",
|
|
||||||
})
|
})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -63,27 +114,90 @@ func (h *Example) Create(w http.ResponseWriter, r *http.Request) error {
|
|||||||
var req CreateRequest
|
var req CreateRequest
|
||||||
|
|
||||||
// Bind and validate request body
|
// Bind and validate request body
|
||||||
// Returns HTTPError on failure, which Wrap will handle
|
|
||||||
if err := app.BindAndValidate(r, &req); err != nil {
|
if err := app.BindAndValidate(r, &req); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Example: business logic error
|
// Example: Check for duplicates
|
||||||
// if exists(req.Name) {
|
// if exists, _ := h.repo.GetByName(r.Context(), req.Name); exists != nil {
|
||||||
// return httperror.Conflict("example with this name already exists")
|
// return httperror.Conflict("example with this name already exists")
|
||||||
// }
|
// }
|
||||||
|
|
||||||
// Example: internal error (will be logged, generic message returned to client)
|
// Example: Create in database
|
||||||
// if err := db.Create(item); err != nil {
|
// item, err := h.repo.Create(r.Context(), req)
|
||||||
// h.logger.Error("failed to create example", "error", err)
|
// if err != nil {
|
||||||
// return err // Generic errors become 500 Internal Error
|
// return err
|
||||||
// }
|
// }
|
||||||
|
|
||||||
// Success response
|
// Example: Access authenticated user
|
||||||
httpresponse.Created(w, r, CreateResponse{
|
// user := auth.GetUser(r.Context())
|
||||||
ID: "example-456",
|
// 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,
|
Name: req.Name,
|
||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
|
CreatedAt: "2024-01-15T10:30:00Z",
|
||||||
|
UpdatedAt: "2024-01-15T10:30:00Z",
|
||||||
})
|
})
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,23 +3,46 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"{{GO_MODULE}}/pkg/app"
|
"{{GO_MODULE}}/pkg/app"
|
||||||
|
"{{GO_MODULE}}/pkg/auth"
|
||||||
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/api/handlers"
|
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/api/handlers"
|
||||||
|
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RegisterRoutes registers all HTTP routes for the service.
|
// RegisterRoutes registers all HTTP routes for the service.
|
||||||
func RegisterRoutes(application *app.App) {
|
func RegisterRoutes(application *app.App) {
|
||||||
logger := application.Logger()
|
logger := application.Logger()
|
||||||
|
cfg := config.Load()
|
||||||
|
|
||||||
// Initialize handlers
|
// Initialize handlers
|
||||||
healthHandler := handlers.NewHealth(logger)
|
healthHandler := handlers.NewHealth(logger)
|
||||||
exampleHandler := handlers.NewExample(logger)
|
exampleHandler := handlers.NewExample(logger)
|
||||||
|
|
||||||
|
// Build and mount OpenAPI spec
|
||||||
|
spec := NewServiceSpec()
|
||||||
|
application.EnableDocs(spec)
|
||||||
|
|
||||||
// Register API routes
|
// Register API routes
|
||||||
application.Route("/api/v1", func(r app.Router) {
|
application.Route("/api/v1", func(r app.Router) {
|
||||||
r.Get("/health", healthHandler.Check)
|
r.Get("/health", healthHandler.Check)
|
||||||
|
|
||||||
// Example routes using Wrap pattern for error-returning handlers
|
// Public routes (no auth required)
|
||||||
r.Get("/example", app.Wrap(exampleHandler.Get))
|
r.Get("/examples", app.Wrap(exampleHandler.List))
|
||||||
r.Post("/example", app.Wrap(exampleHandler.Create))
|
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))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
@ -2,6 +2,9 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"{{GO_MODULE}}/pkg/config"
|
"{{GO_MODULE}}/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -11,22 +14,21 @@ type Config struct {
|
|||||||
Server config.ServerConfig
|
Server config.ServerConfig
|
||||||
Database config.DatabaseConfig
|
Database config.DatabaseConfig
|
||||||
Logging config.LoggingConfig
|
Logging config.LoggingConfig
|
||||||
// Add service-specific config fields here
|
|
||||||
|
// Auth
|
||||||
|
AuthEnabled bool
|
||||||
|
JWTSecret string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads configuration from environment variables.
|
// Load reads configuration from environment variables.
|
||||||
func Load() (*Config, error) {
|
func Load() *Config {
|
||||||
if err := config.Init(config.Options{
|
|
||||||
AppName: "{{COMPONENT_NAME}}",
|
|
||||||
DefaultPort: {{PORT}},
|
|
||||||
}); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &Config{
|
return &Config{
|
||||||
AppConfig: config.ReadAppConfig(),
|
AppConfig: config.ReadAppConfig(),
|
||||||
Server: config.ReadServerConfig(),
|
Server: config.ReadServerConfig(),
|
||||||
Database: config.ReadDatabaseConfig(),
|
Database: config.ReadDatabaseConfig(),
|
||||||
Logging: config.ReadLoggingConfig(),
|
Logging: config.ReadLoggingConfig(),
|
||||||
}, nil
|
|
||||||
|
AuthEnabled: strings.EqualFold(os.Getenv("AUTH_ENABLED"), "true"),
|
||||||
|
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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))
|
||||||
|
```
|
||||||
@ -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).
|
||||||
@ -8,6 +8,8 @@
|
|||||||
|-------------------|-----------|
|
|-------------------|-----------|
|
||||||
| **Set up local dev** | [local/setup.md](.claude/guides/local/setup.md) |
|
| **Set up local dev** | [local/setup.md](.claude/guides/local/setup.md) |
|
||||||
| **Build a feature** | [feature-development.md](.claude/guides/feature-development.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) |
|
| **Deploy** | [ops/deploying.md](.claude/guides/ops/deploying.md) |
|
||||||
|
|
||||||
## Quick Reference
|
## Quick Reference
|
||||||
@ -23,6 +25,17 @@
|
|||||||
./scripts/discover.sh
|
./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
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
@ -31,8 +44,24 @@
|
|||||||
├── workers/ # Background workers (no port)
|
├── workers/ # Background workers (no port)
|
||||||
├── apps/ # Frontend applications (port 3001+)
|
├── apps/ # Frontend applications (port 3001+)
|
||||||
├── cli/ # CLI tools (no port)
|
├── cli/ # CLI tools (no port)
|
||||||
├── packages/ # Shared TypeScript packages (@{{PROJECT_NAME}}/*)
|
├── packages/ # Shared TypeScript packages
|
||||||
├── pkg/ # Shared Go packages ({{GO_MODULE}}/pkg/*)
|
│ ├── 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
|
└── scripts/ # Development & CI scripts
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -40,7 +69,7 @@
|
|||||||
|------|----------|------------|---------|
|
|------|----------|------------|---------|
|
||||||
| services/ | Go | 8001+ | REST APIs, backend services |
|
| services/ | Go | 8001+ | REST APIs, backend services |
|
||||||
| workers/ | Go | none | Background jobs, queue consumers |
|
| 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 |
|
| cli/ | Go | none | CLI tools, scripts |
|
||||||
| packages/ | TypeScript | none | Shared frontend packages |
|
| packages/ | TypeScript | none | Shared frontend packages |
|
||||||
| pkg/ | Go | none | Shared backend packages |
|
| pkg/ | Go | none | Shared backend packages |
|
||||||
|
|||||||
@ -20,6 +20,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-checkbox": "^1.0.4",
|
"@radix-ui/react-checkbox": "^1.0.4",
|
||||||
"@radix-ui/react-dialog": "^1.0.5",
|
"@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-label": "^2.0.2",
|
||||||
"@radix-ui/react-select": "^2.0.0",
|
"@radix-ui/react-select": "^2.0.0",
|
||||||
"@radix-ui/react-slot": "^1.0.2",
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
|
|||||||
@ -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 };
|
||||||
@ -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,
|
||||||
|
};
|
||||||
@ -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,
|
||||||
|
};
|
||||||
@ -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 };
|
||||||
@ -11,6 +11,10 @@ export { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, Dialog
|
|||||||
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from './components/Table';
|
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from './components/Table';
|
||||||
export { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from './components/Select';
|
export { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from './components/Select';
|
||||||
export { Checkbox, type CheckboxProps } from './components/Checkbox';
|
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)
|
// Icons (re-export commonly used ones)
|
||||||
export {
|
export {
|
||||||
|
|||||||
@ -27,6 +27,7 @@ import (
|
|||||||
"{{GO_MODULE}}/pkg/httpresponse"
|
"{{GO_MODULE}}/pkg/httpresponse"
|
||||||
"{{GO_MODULE}}/pkg/logging"
|
"{{GO_MODULE}}/pkg/logging"
|
||||||
"{{GO_MODULE}}/pkg/middleware"
|
"{{GO_MODULE}}/pkg/middleware"
|
||||||
|
"{{GO_MODULE}}/pkg/openapi"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Router is an alias for chi.Router, exposing it for handler mounting.
|
// 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()
|
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.
|
// ServeHTTP implements http.Handler, allowing App to be used in tests.
|
||||||
func (a *App) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (a *App) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
a.router.ServeHTTP(w, r)
|
a.router.ServeHTTP(w, r)
|
||||||
|
|||||||
@ -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
|
||||||
|
)
|
||||||
@ -3,9 +3,11 @@ module {{GO_MODULE}}/pkg
|
|||||||
go 1.23
|
go 1.23
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/bdpiprava/scalar-go v0.1.2
|
||||||
github.com/go-chi/chi/v5 v5.2.0
|
github.com/go-chi/chi/v5 v5.2.0
|
||||||
github.com/go-chi/cors v1.2.1
|
github.com/go-chi/cors v1.2.1
|
||||||
github.com/go-playground/validator/v10 v10.23.0
|
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/google/uuid v1.6.0
|
||||||
github.com/spf13/viper v1.19.0
|
github.com/spf13/viper v1.19.0
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -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()),
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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"))
|
||||||
|
}
|
||||||
@ -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"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,11 +7,12 @@ import "regexp"
|
|||||||
type ComponentType string
|
type ComponentType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ComponentTypeService ComponentType = "service"
|
ComponentTypeService ComponentType = "service"
|
||||||
ComponentTypeWorker ComponentType = "worker"
|
ComponentTypeWorker ComponentType = "worker"
|
||||||
ComponentTypeAppAstro ComponentType = "app-astro"
|
ComponentTypeAppAstro ComponentType = "app-astro"
|
||||||
ComponentTypeAppReact ComponentType = "app-react"
|
ComponentTypeAppReact ComponentType = "app-react"
|
||||||
ComponentTypeCLI ComponentType = "cli"
|
ComponentTypeAppNextJS ComponentType = "app-nextjs"
|
||||||
|
ComponentTypeCLI ComponentType = "cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ValidComponentTypes lists all valid component types.
|
// ValidComponentTypes lists all valid component types.
|
||||||
@ -20,6 +21,7 @@ var ValidComponentTypes = []ComponentType{
|
|||||||
ComponentTypeWorker,
|
ComponentTypeWorker,
|
||||||
ComponentTypeAppAstro,
|
ComponentTypeAppAstro,
|
||||||
ComponentTypeAppReact,
|
ComponentTypeAppReact,
|
||||||
|
ComponentTypeAppNextJS,
|
||||||
ComponentTypeCLI,
|
ComponentTypeCLI,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,7 +52,7 @@ func (c ComponentType) DestDir() string {
|
|||||||
return "services"
|
return "services"
|
||||||
case ComponentTypeWorker:
|
case ComponentTypeWorker:
|
||||||
return "workers"
|
return "workers"
|
||||||
case ComponentTypeAppAstro, ComponentTypeAppReact:
|
case ComponentTypeAppAstro, ComponentTypeAppReact, ComponentTypeAppNextJS:
|
||||||
return "apps"
|
return "apps"
|
||||||
case ComponentTypeCLI:
|
case ComponentTypeCLI:
|
||||||
return "cli"
|
return "cli"
|
||||||
@ -65,7 +67,7 @@ func (c ComponentType) StartingPort() int {
|
|||||||
switch c {
|
switch c {
|
||||||
case ComponentTypeService:
|
case ComponentTypeService:
|
||||||
return 8001
|
return 8001
|
||||||
case ComponentTypeAppAstro, ComponentTypeAppReact:
|
case ComponentTypeAppAstro, ComponentTypeAppReact, ComponentTypeAppNextJS:
|
||||||
return 3001
|
return 3001
|
||||||
case ComponentTypeWorker, ComponentTypeCLI:
|
case ComponentTypeWorker, ComponentTypeCLI:
|
||||||
return 0
|
return 0
|
||||||
@ -76,7 +78,7 @@ func (c ComponentType) StartingPort() int {
|
|||||||
|
|
||||||
// NeedsPort returns true if this component type requires a port assignment.
|
// NeedsPort returns true if this component type requires a port assignment.
|
||||||
func (c ComponentType) NeedsPort() bool {
|
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).
|
// IsGoComponent returns true if this component type uses Go (and needs go.work entry).
|
||||||
|
|||||||
@ -12,6 +12,7 @@ func TestIsValidComponentType(t *testing.T) {
|
|||||||
{"worker", "worker", true},
|
{"worker", "worker", true},
|
||||||
{"app-astro", "app-astro", true},
|
{"app-astro", "app-astro", true},
|
||||||
{"app-react", "app-react", true},
|
{"app-react", "app-react", true},
|
||||||
|
{"app-nextjs", "app-nextjs", true},
|
||||||
{"cli", "cli", true},
|
{"cli", "cli", true},
|
||||||
{"invalid", "invalid", false},
|
{"invalid", "invalid", false},
|
||||||
{"empty", "", false},
|
{"empty", "", false},
|
||||||
@ -39,6 +40,7 @@ func TestComponentType_DestDir(t *testing.T) {
|
|||||||
{"worker", ComponentTypeWorker, "workers"},
|
{"worker", ComponentTypeWorker, "workers"},
|
||||||
{"app-astro", ComponentTypeAppAstro, "apps"},
|
{"app-astro", ComponentTypeAppAstro, "apps"},
|
||||||
{"app-react", ComponentTypeAppReact, "apps"},
|
{"app-react", ComponentTypeAppReact, "apps"},
|
||||||
|
{"app-nextjs", ComponentTypeAppNextJS, "apps"},
|
||||||
{"cli", ComponentTypeCLI, "cli"},
|
{"cli", ComponentTypeCLI, "cli"},
|
||||||
{"unknown", ComponentType("unknown"), ""},
|
{"unknown", ComponentType("unknown"), ""},
|
||||||
}
|
}
|
||||||
@ -63,6 +65,7 @@ func TestComponentType_StartingPort(t *testing.T) {
|
|||||||
{"worker", ComponentTypeWorker, 0},
|
{"worker", ComponentTypeWorker, 0},
|
||||||
{"app-astro", ComponentTypeAppAstro, 3001},
|
{"app-astro", ComponentTypeAppAstro, 3001},
|
||||||
{"app-react", ComponentTypeAppReact, 3001},
|
{"app-react", ComponentTypeAppReact, 3001},
|
||||||
|
{"app-nextjs", ComponentTypeAppNextJS, 3001},
|
||||||
{"cli", ComponentTypeCLI, 0},
|
{"cli", ComponentTypeCLI, 0},
|
||||||
{"unknown", ComponentType("unknown"), 0},
|
{"unknown", ComponentType("unknown"), 0},
|
||||||
}
|
}
|
||||||
@ -87,6 +90,7 @@ func TestComponentType_NeedsPort(t *testing.T) {
|
|||||||
{"worker", ComponentTypeWorker, false},
|
{"worker", ComponentTypeWorker, false},
|
||||||
{"app-astro", ComponentTypeAppAstro, true},
|
{"app-astro", ComponentTypeAppAstro, true},
|
||||||
{"app-react", ComponentTypeAppReact, true},
|
{"app-react", ComponentTypeAppReact, true},
|
||||||
|
{"app-nextjs", ComponentTypeAppNextJS, true},
|
||||||
{"cli", ComponentTypeCLI, false},
|
{"cli", ComponentTypeCLI, false},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -110,6 +114,7 @@ func TestComponentType_IsGoComponent(t *testing.T) {
|
|||||||
{"worker", ComponentTypeWorker, true},
|
{"worker", ComponentTypeWorker, true},
|
||||||
{"app-astro", ComponentTypeAppAstro, false},
|
{"app-astro", ComponentTypeAppAstro, false},
|
||||||
{"app-react", ComponentTypeAppReact, false},
|
{"app-react", ComponentTypeAppReact, false},
|
||||||
|
{"app-nextjs", ComponentTypeAppNextJS, false},
|
||||||
{"cli", ComponentTypeCLI, true},
|
{"cli", ComponentTypeCLI, true},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -161,6 +166,7 @@ func TestValidComponentTypes(t *testing.T) {
|
|||||||
ComponentTypeWorker,
|
ComponentTypeWorker,
|
||||||
ComponentTypeAppAstro,
|
ComponentTypeAppAstro,
|
||||||
ComponentTypeAppReact,
|
ComponentTypeAppReact,
|
||||||
|
ComponentTypeAppNextJS,
|
||||||
ComponentTypeCLI,
|
ComponentTypeCLI,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
129
internal/handlers/sdlc.go
Normal file
129
internal/handlers/sdlc.go
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/orchard9/rdev/internal/domain"
|
||||||
|
"github.com/orchard9/rdev/internal/sdlc"
|
||||||
|
"github.com/orchard9/rdev/internal/service"
|
||||||
|
"github.com/orchard9/rdev/pkg/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SDLCHandler handles SDLC endpoints for project lifecycle management.
|
||||||
|
type SDLCHandler struct {
|
||||||
|
sdlcService *service.SDLCService
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSDLCHandler creates a new SDLC handler.
|
||||||
|
func NewSDLCHandler(sdlcService *service.SDLCService, logger *slog.Logger) *SDLCHandler {
|
||||||
|
if logger == nil {
|
||||||
|
logger = slog.Default()
|
||||||
|
}
|
||||||
|
return &SDLCHandler{
|
||||||
|
sdlcService: sdlcService,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mount registers all SDLC routes under /projects/{id}/sdlc/.
|
||||||
|
func (h *SDLCHandler) Mount(r api.Router) {
|
||||||
|
r.Route("/projects/{id}/sdlc", func(r chi.Router) {
|
||||||
|
// State
|
||||||
|
r.Get("/state", h.GetState)
|
||||||
|
r.Get("/next", h.GetNext)
|
||||||
|
|
||||||
|
// Features
|
||||||
|
r.Get("/features", h.ListFeatures)
|
||||||
|
r.Post("/features", h.CreateFeature)
|
||||||
|
r.Get("/features/{slug}", h.GetFeature)
|
||||||
|
r.Post("/features/{slug}/transition", h.TransitionFeature)
|
||||||
|
r.Post("/features/{slug}/block", h.BlockFeature)
|
||||||
|
r.Post("/features/{slug}/unblock", h.UnblockFeature)
|
||||||
|
r.Delete("/features/{slug}", h.DeleteFeature)
|
||||||
|
|
||||||
|
// Artifacts
|
||||||
|
r.Get("/features/{slug}/artifacts", h.GetArtifactStatus)
|
||||||
|
r.Post("/features/{slug}/artifacts/{type}/approve", h.ApproveArtifact)
|
||||||
|
r.Post("/features/{slug}/artifacts/{type}/reject", h.RejectArtifact)
|
||||||
|
|
||||||
|
// Tasks
|
||||||
|
r.Get("/features/{slug}/tasks", h.ListTasks)
|
||||||
|
r.Post("/features/{slug}/tasks", h.AddTask)
|
||||||
|
r.Post("/features/{slug}/tasks/{taskId}/start", h.StartTask)
|
||||||
|
r.Post("/features/{slug}/tasks/{taskId}/complete", h.CompleteTask)
|
||||||
|
r.Post("/features/{slug}/tasks/{taskId}/block", h.BlockTask)
|
||||||
|
|
||||||
|
// Queries
|
||||||
|
r.Get("/query/blocked", h.QueryBlocked)
|
||||||
|
r.Get("/query/ready", h.QueryReady)
|
||||||
|
r.Get("/query/needs-approval", h.QueryNeedsApproval)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetState returns the global SDLC state for a project.
|
||||||
|
// GET /projects/{id}/sdlc/state
|
||||||
|
func (h *SDLCHandler) GetState(w http.ResponseWriter, r *http.Request) {
|
||||||
|
projectID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
state, err := h.sdlcService.GetState(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
writeSDLCError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
api.WriteSuccess(w, r, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNext returns the classifier's recommendation for the next action.
|
||||||
|
// GET /projects/{id}/sdlc/next?feature=slug
|
||||||
|
func (h *SDLCHandler) GetNext(w http.ResponseWriter, r *http.Request) {
|
||||||
|
projectID := chi.URLParam(r, "id")
|
||||||
|
feature := r.URL.Query().Get("feature")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cl, err := h.sdlcService.GetNext(ctx, projectID, feature)
|
||||||
|
if err != nil {
|
||||||
|
writeSDLCError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
api.WriteSuccess(w, r, cl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeSDLCError maps SDLC domain errors to HTTP responses.
|
||||||
|
func writeSDLCError(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, domain.ErrProjectNotFound):
|
||||||
|
api.WriteNotFound(w, r, "project not found")
|
||||||
|
case errors.Is(err, sdlc.ErrNotInitialized):
|
||||||
|
api.WriteNotFound(w, r, "sdlc not initialized for this project")
|
||||||
|
case errors.Is(err, sdlc.ErrFeatureNotFound):
|
||||||
|
api.WriteNotFound(w, r, "feature not found")
|
||||||
|
case errors.Is(err, sdlc.ErrTaskNotFound):
|
||||||
|
api.WriteNotFound(w, r, "task not found")
|
||||||
|
case errors.Is(err, sdlc.ErrArtifactNotFound):
|
||||||
|
api.WriteNotFound(w, r, "artifact not found")
|
||||||
|
case errors.Is(err, sdlc.ErrFeatureExists):
|
||||||
|
api.WriteBadRequest(w, r, "feature already exists")
|
||||||
|
case errors.Is(err, sdlc.ErrInvalidTransition):
|
||||||
|
api.WriteBadRequest(w, r, err.Error())
|
||||||
|
case errors.Is(err, sdlc.ErrInvalidPhase):
|
||||||
|
api.WriteBadRequest(w, r, "invalid phase")
|
||||||
|
case errors.Is(err, sdlc.ErrInvalidSlug):
|
||||||
|
api.WriteBadRequest(w, r, "invalid slug: must be lowercase alphanumeric with hyphens")
|
||||||
|
case errors.Is(err, sdlc.ErrInvalidArtifact):
|
||||||
|
api.WriteBadRequest(w, r, "invalid artifact type")
|
||||||
|
default:
|
||||||
|
api.WriteInternalError(w, r, "sdlc operation failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
84
internal/handlers/sdlc_artifacts.go
Normal file
84
internal/handlers/sdlc_artifacts.go
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/orchard9/rdev/internal/sdlc"
|
||||||
|
"github.com/orchard9/rdev/pkg/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetArtifactStatus returns artifact statuses for a feature.
|
||||||
|
// GET /projects/{id}/sdlc/features/{slug}/artifacts
|
||||||
|
func (h *SDLCHandler) GetArtifactStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
projectID := chi.URLParam(r, "id")
|
||||||
|
slug := chi.URLParam(r, "slug")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
artifacts, err := h.sdlcService.GetArtifactStatus(ctx, projectID, slug)
|
||||||
|
if err != nil {
|
||||||
|
writeSDLCError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
api.WriteSuccess(w, r, artifacts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApproveArtifact approves a feature artifact.
|
||||||
|
// POST /projects/{id}/sdlc/features/{slug}/artifacts/{type}/approve
|
||||||
|
func (h *SDLCHandler) ApproveArtifact(w http.ResponseWriter, r *http.Request) {
|
||||||
|
projectID := chi.URLParam(r, "id")
|
||||||
|
slug := chi.URLParam(r, "slug")
|
||||||
|
artTypeStr := chi.URLParam(r, "type")
|
||||||
|
|
||||||
|
artType := sdlc.ArtifactType(artTypeStr)
|
||||||
|
if !sdlc.IsValidArtifactType(artType) {
|
||||||
|
api.WriteBadRequest(w, r, "invalid artifact type: "+artTypeStr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), TimeoutHeavyWrite)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := h.sdlcService.ApproveArtifact(ctx, projectID, slug, artType); err != nil {
|
||||||
|
writeSDLCError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
api.WriteSuccess(w, r, map[string]any{
|
||||||
|
"feature": slug,
|
||||||
|
"artifact": artTypeStr,
|
||||||
|
"status": "approved",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RejectArtifact rejects a feature artifact.
|
||||||
|
// POST /projects/{id}/sdlc/features/{slug}/artifacts/{type}/reject
|
||||||
|
func (h *SDLCHandler) RejectArtifact(w http.ResponseWriter, r *http.Request) {
|
||||||
|
projectID := chi.URLParam(r, "id")
|
||||||
|
slug := chi.URLParam(r, "slug")
|
||||||
|
artTypeStr := chi.URLParam(r, "type")
|
||||||
|
|
||||||
|
artType := sdlc.ArtifactType(artTypeStr)
|
||||||
|
if !sdlc.IsValidArtifactType(artType) {
|
||||||
|
api.WriteBadRequest(w, r, "invalid artifact type: "+artTypeStr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), TimeoutHeavyWrite)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := h.sdlcService.RejectArtifact(ctx, projectID, slug, artType); err != nil {
|
||||||
|
writeSDLCError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
api.WriteSuccess(w, r, map[string]any{
|
||||||
|
"feature": slug,
|
||||||
|
"artifact": artTypeStr,
|
||||||
|
"status": "rejected",
|
||||||
|
})
|
||||||
|
}
|
||||||
202
internal/handlers/sdlc_features.go
Normal file
202
internal/handlers/sdlc_features.go
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/orchard9/rdev/internal/sdlc"
|
||||||
|
"github.com/orchard9/rdev/internal/validate"
|
||||||
|
"github.com/orchard9/rdev/pkg/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreateFeatureRequest is the request body for POST /projects/{id}/sdlc/features.
|
||||||
|
type CreateFeatureRequest struct {
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TransitionFeatureRequest is the request body for POST /projects/{id}/sdlc/features/{slug}/transition.
|
||||||
|
type TransitionFeatureRequest struct {
|
||||||
|
Phase string `json:"phase"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockFeatureRequest is the request body for POST /projects/{id}/sdlc/features/{slug}/block.
|
||||||
|
type BlockFeatureRequest struct {
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListFeatures returns all features in a project.
|
||||||
|
// GET /projects/{id}/sdlc/features
|
||||||
|
func (h *SDLCHandler) ListFeatures(w http.ResponseWriter, r *http.Request) {
|
||||||
|
projectID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
features, err := h.sdlcService.ListFeatures(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
writeSDLCError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if features == nil {
|
||||||
|
features = []*sdlc.Feature{}
|
||||||
|
}
|
||||||
|
api.WriteSuccess(w, r, features)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFeature returns a single feature by slug.
|
||||||
|
// GET /projects/{id}/sdlc/features/{slug}
|
||||||
|
func (h *SDLCHandler) GetFeature(w http.ResponseWriter, r *http.Request) {
|
||||||
|
projectID := chi.URLParam(r, "id")
|
||||||
|
slug := chi.URLParam(r, "slug")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
feature, err := h.sdlcService.GetFeature(ctx, projectID, slug)
|
||||||
|
if err != nil {
|
||||||
|
writeSDLCError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
api.WriteSuccess(w, r, feature)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateFeature creates a new feature.
|
||||||
|
// POST /projects/{id}/sdlc/features
|
||||||
|
func (h *SDLCHandler) CreateFeature(w http.ResponseWriter, r *http.Request) {
|
||||||
|
projectID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
var req CreateFeatureRequest
|
||||||
|
if err := api.DecodeJSON(r, &req); err != nil {
|
||||||
|
api.WriteBadRequest(w, r, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
v := validate.New()
|
||||||
|
v.Required(req.Slug, "slug")
|
||||||
|
v.Required(req.Title, "title")
|
||||||
|
if err := v.Error(); err != nil {
|
||||||
|
api.WriteBadRequest(w, r, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), TimeoutHeavyWrite)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
feature, err := h.sdlcService.CreateFeature(ctx, projectID, req.Slug, req.Title)
|
||||||
|
if err != nil {
|
||||||
|
writeSDLCError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
api.WriteCreated(w, r, feature)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TransitionFeature moves a feature to a new phase.
|
||||||
|
// POST /projects/{id}/sdlc/features/{slug}/transition
|
||||||
|
func (h *SDLCHandler) TransitionFeature(w http.ResponseWriter, r *http.Request) {
|
||||||
|
projectID := chi.URLParam(r, "id")
|
||||||
|
slug := chi.URLParam(r, "slug")
|
||||||
|
|
||||||
|
var req TransitionFeatureRequest
|
||||||
|
if err := api.DecodeJSON(r, &req); err != nil {
|
||||||
|
api.WriteBadRequest(w, r, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Phase == "" {
|
||||||
|
api.WriteBadRequest(w, r, "phase is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
phase := sdlc.FeaturePhase(req.Phase)
|
||||||
|
if !sdlc.IsValidPhase(phase) {
|
||||||
|
api.WriteBadRequest(w, r, "invalid phase: "+req.Phase)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), TimeoutHeavyWrite)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := h.sdlcService.TransitionFeature(ctx, projectID, slug, phase); err != nil {
|
||||||
|
writeSDLCError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
api.WriteSuccess(w, r, map[string]any{
|
||||||
|
"feature": slug,
|
||||||
|
"phase": req.Phase,
|
||||||
|
"message": "feature transitioned successfully",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockFeature blocks a feature with a reason.
|
||||||
|
// POST /projects/{id}/sdlc/features/{slug}/block
|
||||||
|
func (h *SDLCHandler) BlockFeature(w http.ResponseWriter, r *http.Request) {
|
||||||
|
projectID := chi.URLParam(r, "id")
|
||||||
|
slug := chi.URLParam(r, "slug")
|
||||||
|
|
||||||
|
var req BlockFeatureRequest
|
||||||
|
if err := api.DecodeJSON(r, &req); err != nil {
|
||||||
|
api.WriteBadRequest(w, r, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Reason == "" {
|
||||||
|
api.WriteBadRequest(w, r, "reason is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), TimeoutHeavyWrite)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := h.sdlcService.BlockFeature(ctx, projectID, slug, req.Reason); err != nil {
|
||||||
|
writeSDLCError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
api.WriteSuccess(w, r, map[string]any{
|
||||||
|
"feature": slug,
|
||||||
|
"message": "feature blocked",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnblockFeature removes all blockers from a feature.
|
||||||
|
// POST /projects/{id}/sdlc/features/{slug}/unblock
|
||||||
|
func (h *SDLCHandler) UnblockFeature(w http.ResponseWriter, r *http.Request) {
|
||||||
|
projectID := chi.URLParam(r, "id")
|
||||||
|
slug := chi.URLParam(r, "slug")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), TimeoutHeavyWrite)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := h.sdlcService.UnblockFeature(ctx, projectID, slug); err != nil {
|
||||||
|
writeSDLCError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
api.WriteSuccess(w, r, map[string]any{
|
||||||
|
"feature": slug,
|
||||||
|
"message": "feature unblocked",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteFeature removes a feature.
|
||||||
|
// DELETE /projects/{id}/sdlc/features/{slug}
|
||||||
|
func (h *SDLCHandler) DeleteFeature(w http.ResponseWriter, r *http.Request) {
|
||||||
|
projectID := chi.URLParam(r, "id")
|
||||||
|
slug := chi.URLParam(r, "slug")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), TimeoutHeavyWrite)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := h.sdlcService.DeleteFeature(ctx, projectID, slug); err != nil {
|
||||||
|
writeSDLCError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
api.WriteNoContent(w)
|
||||||
|
}
|
||||||
60
internal/handlers/sdlc_queries.go
Normal file
60
internal/handlers/sdlc_queries.go
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/orchard9/rdev/pkg/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// QueryBlocked returns all blocked features in a project.
|
||||||
|
// GET /projects/{id}/sdlc/query/blocked
|
||||||
|
func (h *SDLCHandler) QueryBlocked(w http.ResponseWriter, r *http.Request) {
|
||||||
|
projectID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
blocked, err := h.sdlcService.QueryBlocked(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
writeSDLCError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
api.WriteSuccess(w, r, blocked)
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryReady returns features ready for work in a project.
|
||||||
|
// GET /projects/{id}/sdlc/query/ready
|
||||||
|
func (h *SDLCHandler) QueryReady(w http.ResponseWriter, r *http.Request) {
|
||||||
|
projectID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
ready, err := h.sdlcService.QueryReady(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
writeSDLCError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
api.WriteSuccess(w, r, ready)
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryNeedsApproval returns features awaiting approval in a project.
|
||||||
|
// GET /projects/{id}/sdlc/query/needs-approval
|
||||||
|
func (h *SDLCHandler) QueryNeedsApproval(w http.ResponseWriter, r *http.Request) {
|
||||||
|
projectID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
pending, err := h.sdlcService.QueryNeedsApproval(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
writeSDLCError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
api.WriteSuccess(w, r, pending)
|
||||||
|
}
|
||||||
134
internal/handlers/sdlc_tasks.go
Normal file
134
internal/handlers/sdlc_tasks.go
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/orchard9/rdev/internal/sdlc"
|
||||||
|
"github.com/orchard9/rdev/internal/validate"
|
||||||
|
"github.com/orchard9/rdev/pkg/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AddTaskRequest is the request body for POST /projects/{id}/sdlc/features/{slug}/tasks.
|
||||||
|
type AddTaskRequest struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTasks returns all tasks for a feature.
|
||||||
|
// GET /projects/{id}/sdlc/features/{slug}/tasks
|
||||||
|
func (h *SDLCHandler) ListTasks(w http.ResponseWriter, r *http.Request) {
|
||||||
|
projectID := chi.URLParam(r, "id")
|
||||||
|
slug := chi.URLParam(r, "slug")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
tasks, err := h.sdlcService.ListTasks(ctx, projectID, slug)
|
||||||
|
if err != nil {
|
||||||
|
writeSDLCError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if tasks == nil {
|
||||||
|
tasks = []sdlc.Task{}
|
||||||
|
}
|
||||||
|
api.WriteSuccess(w, r, tasks)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddTask adds a new task to a feature.
|
||||||
|
// POST /projects/{id}/sdlc/features/{slug}/tasks
|
||||||
|
func (h *SDLCHandler) AddTask(w http.ResponseWriter, r *http.Request) {
|
||||||
|
projectID := chi.URLParam(r, "id")
|
||||||
|
slug := chi.URLParam(r, "slug")
|
||||||
|
|
||||||
|
var req AddTaskRequest
|
||||||
|
if err := api.DecodeJSON(r, &req); err != nil {
|
||||||
|
api.WriteBadRequest(w, r, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
v := validate.New()
|
||||||
|
v.Required(req.Title, "title")
|
||||||
|
if err := v.Error(); err != nil {
|
||||||
|
api.WriteBadRequest(w, r, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), TimeoutHeavyWrite)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
task, err := h.sdlcService.AddTask(ctx, projectID, slug, req.Title)
|
||||||
|
if err != nil {
|
||||||
|
writeSDLCError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
api.WriteCreated(w, r, task)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartTask marks a task as in-progress.
|
||||||
|
// POST /projects/{id}/sdlc/features/{slug}/tasks/{taskId}/start
|
||||||
|
func (h *SDLCHandler) StartTask(w http.ResponseWriter, r *http.Request) {
|
||||||
|
projectID := chi.URLParam(r, "id")
|
||||||
|
slug := chi.URLParam(r, "slug")
|
||||||
|
taskID := chi.URLParam(r, "taskId")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), TimeoutHeavyWrite)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := h.sdlcService.StartTask(ctx, projectID, slug, taskID); err != nil {
|
||||||
|
writeSDLCError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
api.WriteSuccess(w, r, map[string]any{
|
||||||
|
"feature": slug,
|
||||||
|
"task_id": taskID,
|
||||||
|
"status": "in_progress",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompleteTask marks a task as complete.
|
||||||
|
// POST /projects/{id}/sdlc/features/{slug}/tasks/{taskId}/complete
|
||||||
|
func (h *SDLCHandler) CompleteTask(w http.ResponseWriter, r *http.Request) {
|
||||||
|
projectID := chi.URLParam(r, "id")
|
||||||
|
slug := chi.URLParam(r, "slug")
|
||||||
|
taskID := chi.URLParam(r, "taskId")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), TimeoutHeavyWrite)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := h.sdlcService.CompleteTask(ctx, projectID, slug, taskID); err != nil {
|
||||||
|
writeSDLCError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
api.WriteSuccess(w, r, map[string]any{
|
||||||
|
"feature": slug,
|
||||||
|
"task_id": taskID,
|
||||||
|
"status": "complete",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockTask marks a task as blocked.
|
||||||
|
// POST /projects/{id}/sdlc/features/{slug}/tasks/{taskId}/block
|
||||||
|
func (h *SDLCHandler) BlockTask(w http.ResponseWriter, r *http.Request) {
|
||||||
|
projectID := chi.URLParam(r, "id")
|
||||||
|
slug := chi.URLParam(r, "slug")
|
||||||
|
taskID := chi.URLParam(r, "taskId")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), TimeoutHeavyWrite)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := h.sdlcService.BlockTask(ctx, projectID, slug, taskID); err != nil {
|
||||||
|
writeSDLCError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
api.WriteSuccess(w, r, map[string]any{
|
||||||
|
"feature": slug,
|
||||||
|
"task_id": taskID,
|
||||||
|
"status": "blocked",
|
||||||
|
})
|
||||||
|
}
|
||||||
399
internal/handlers/sdlc_test.go
Normal file
399
internal/handlers/sdlc_test.go
Normal file
@ -0,0 +1,399 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/orchard9/rdev/internal/domain"
|
||||||
|
"github.com/orchard9/rdev/internal/port"
|
||||||
|
"github.com/orchard9/rdev/internal/sdlc"
|
||||||
|
"github.com/orchard9/rdev/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testSDLCExecutor implements port.SDLCExecutor for handler tests.
|
||||||
|
type testSDLCExecutor struct {
|
||||||
|
state *sdlc.State
|
||||||
|
classification *sdlc.Classification
|
||||||
|
features []*sdlc.Feature
|
||||||
|
feature *sdlc.Feature
|
||||||
|
artifacts map[sdlc.ArtifactType]*sdlc.Artifact
|
||||||
|
tasks []sdlc.Task
|
||||||
|
task *sdlc.Task
|
||||||
|
blocked []port.BlockedInfo
|
||||||
|
ready []port.ReadyInfo
|
||||||
|
approval []port.ApprovalInfo
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *testSDLCExecutor) GetState(_ context.Context, _ string) (*sdlc.State, error) {
|
||||||
|
return m.state, m.err
|
||||||
|
}
|
||||||
|
func (m *testSDLCExecutor) GetNext(_ context.Context, _, _ string) (*sdlc.Classification, error) {
|
||||||
|
return m.classification, m.err
|
||||||
|
}
|
||||||
|
func (m *testSDLCExecutor) ListFeatures(_ context.Context, _ string) ([]*sdlc.Feature, error) {
|
||||||
|
return m.features, m.err
|
||||||
|
}
|
||||||
|
func (m *testSDLCExecutor) GetFeature(_ context.Context, _, _ string) (*sdlc.Feature, error) {
|
||||||
|
return m.feature, m.err
|
||||||
|
}
|
||||||
|
func (m *testSDLCExecutor) CreateFeature(_ context.Context, _, slug, title string) (*sdlc.Feature, error) {
|
||||||
|
if m.err != nil {
|
||||||
|
return nil, m.err
|
||||||
|
}
|
||||||
|
return &sdlc.Feature{Slug: slug, Title: title, Phase: sdlc.PhaseDraft}, nil
|
||||||
|
}
|
||||||
|
func (m *testSDLCExecutor) TransitionFeature(_ context.Context, _, _ string, _ sdlc.FeaturePhase) error {
|
||||||
|
return m.err
|
||||||
|
}
|
||||||
|
func (m *testSDLCExecutor) BlockFeature(_ context.Context, _, _, _ string) error { return m.err }
|
||||||
|
func (m *testSDLCExecutor) UnblockFeature(_ context.Context, _, _ string) error { return m.err }
|
||||||
|
func (m *testSDLCExecutor) DeleteFeature(_ context.Context, _, _ string) error { return m.err }
|
||||||
|
func (m *testSDLCExecutor) GetArtifactStatus(_ context.Context, _, _ string) (map[sdlc.ArtifactType]*sdlc.Artifact, error) {
|
||||||
|
return m.artifacts, m.err
|
||||||
|
}
|
||||||
|
func (m *testSDLCExecutor) ApproveArtifact(_ context.Context, _, _ string, _ sdlc.ArtifactType) error {
|
||||||
|
return m.err
|
||||||
|
}
|
||||||
|
func (m *testSDLCExecutor) RejectArtifact(_ context.Context, _, _ string, _ sdlc.ArtifactType) error {
|
||||||
|
return m.err
|
||||||
|
}
|
||||||
|
func (m *testSDLCExecutor) ListTasks(_ context.Context, _, _ string) ([]sdlc.Task, error) {
|
||||||
|
return m.tasks, m.err
|
||||||
|
}
|
||||||
|
func (m *testSDLCExecutor) AddTask(_ context.Context, _, _, title string) (*sdlc.Task, error) {
|
||||||
|
if m.err != nil {
|
||||||
|
return nil, m.err
|
||||||
|
}
|
||||||
|
if m.task != nil {
|
||||||
|
return m.task, nil
|
||||||
|
}
|
||||||
|
return &sdlc.Task{ID: "task-001", Title: title, Status: sdlc.TaskPending}, nil
|
||||||
|
}
|
||||||
|
func (m *testSDLCExecutor) StartTask(_ context.Context, _, _, _ string) error { return m.err }
|
||||||
|
func (m *testSDLCExecutor) CompleteTask(_ context.Context, _, _, _ string) error { return m.err }
|
||||||
|
func (m *testSDLCExecutor) BlockTask(_ context.Context, _, _, _ string) error { return m.err }
|
||||||
|
func (m *testSDLCExecutor) QueryBlocked(_ context.Context, _ string) ([]port.BlockedInfo, error) {
|
||||||
|
return m.blocked, m.err
|
||||||
|
}
|
||||||
|
func (m *testSDLCExecutor) QueryReady(_ context.Context, _ string) ([]port.ReadyInfo, error) {
|
||||||
|
return m.ready, m.err
|
||||||
|
}
|
||||||
|
func (m *testSDLCExecutor) QueryNeedsApproval(_ context.Context, _ string) ([]port.ApprovalInfo, error) {
|
||||||
|
return m.approval, m.err
|
||||||
|
}
|
||||||
|
|
||||||
|
// testSDLCProjectRepo implements port.ProjectRepository for handler tests.
|
||||||
|
type testSDLCProjectRepo struct {
|
||||||
|
project *domain.Project
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *testSDLCProjectRepo) Get(_ context.Context, _ domain.ProjectID) (*domain.Project, error) {
|
||||||
|
if m.project == nil {
|
||||||
|
return nil, domain.ErrProjectNotFound
|
||||||
|
}
|
||||||
|
return m.project, nil
|
||||||
|
}
|
||||||
|
func (m *testSDLCProjectRepo) List(_ context.Context) ([]domain.Project, error) { return nil, nil }
|
||||||
|
func (m *testSDLCProjectRepo) Exists(_ context.Context, _ domain.ProjectID) (bool, error) {
|
||||||
|
return m.project != nil, nil
|
||||||
|
}
|
||||||
|
func (m *testSDLCProjectRepo) Register(_ context.Context, _ *domain.Project) error { return nil }
|
||||||
|
func (m *testSDLCProjectRepo) Unregister(_ context.Context, _ domain.ProjectID) error { return nil }
|
||||||
|
func (m *testSDLCProjectRepo) RefreshStatus(_ context.Context) error { return nil }
|
||||||
|
|
||||||
|
func setupSDLCHandler(exec *testSDLCExecutor) (*SDLCHandler, *chi.Mux) {
|
||||||
|
repo := &testSDLCProjectRepo{
|
||||||
|
project: &domain.Project{ID: "test-project", PodName: "test-pod"},
|
||||||
|
}
|
||||||
|
svc := service.NewSDLCService(exec, repo, service.SDLCServiceConfig{})
|
||||||
|
handler := NewSDLCHandler(svc, nil)
|
||||||
|
r := chi.NewRouter()
|
||||||
|
handler.Mount(r)
|
||||||
|
return handler, r
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSDLCHandler_GetState(t *testing.T) {
|
||||||
|
exec := &testSDLCExecutor{
|
||||||
|
state: &sdlc.State{Version: 1},
|
||||||
|
}
|
||||||
|
_, router := setupSDLCHandler(exec)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/projects/test-project/sdlc/state", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSDLCHandler_GetState_NotInitialized(t *testing.T) {
|
||||||
|
exec := &testSDLCExecutor{
|
||||||
|
err: sdlc.ErrNotInitialized,
|
||||||
|
}
|
||||||
|
_, router := setupSDLCHandler(exec)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/projects/test-project/sdlc/state", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("expected status 404, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSDLCHandler_GetState_ProjectNotFound(t *testing.T) {
|
||||||
|
exec := &testSDLCExecutor{}
|
||||||
|
repo := &testSDLCProjectRepo{project: nil}
|
||||||
|
svc := service.NewSDLCService(exec, repo, service.SDLCServiceConfig{})
|
||||||
|
handler := NewSDLCHandler(svc, nil)
|
||||||
|
r := chi.NewRouter()
|
||||||
|
handler.Mount(r)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/projects/nonexistent/sdlc/state", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("expected status 404, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSDLCHandler_CreateFeature(t *testing.T) {
|
||||||
|
exec := &testSDLCExecutor{}
|
||||||
|
_, router := setupSDLCHandler(exec)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(CreateFeatureRequest{Slug: "auth-flow", Title: "Auth Flow"})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Errorf("expected status 201, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSDLCHandler_CreateFeature_MissingFields(t *testing.T) {
|
||||||
|
exec := &testSDLCExecutor{}
|
||||||
|
_, router := setupSDLCHandler(exec)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(CreateFeatureRequest{})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected status 400, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSDLCHandler_CreateFeature_AlreadyExists(t *testing.T) {
|
||||||
|
exec := &testSDLCExecutor{err: sdlc.ErrFeatureExists}
|
||||||
|
_, router := setupSDLCHandler(exec)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(CreateFeatureRequest{Slug: "auth-flow", Title: "Auth Flow"})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected status 400, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSDLCHandler_TransitionFeature(t *testing.T) {
|
||||||
|
exec := &testSDLCExecutor{}
|
||||||
|
_, router := setupSDLCHandler(exec)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(TransitionFeatureRequest{Phase: "specified"})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features/auth-flow/transition", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSDLCHandler_TransitionFeature_InvalidPhase(t *testing.T) {
|
||||||
|
exec := &testSDLCExecutor{}
|
||||||
|
_, router := setupSDLCHandler(exec)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(TransitionFeatureRequest{Phase: "not-a-phase"})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features/auth-flow/transition", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected status 400, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSDLCHandler_TransitionFeature_InvalidTransition(t *testing.T) {
|
||||||
|
exec := &testSDLCExecutor{err: sdlc.ErrInvalidTransition}
|
||||||
|
_, router := setupSDLCHandler(exec)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(TransitionFeatureRequest{Phase: "review"})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features/auth-flow/transition", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected status 400, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSDLCHandler_BlockFeature(t *testing.T) {
|
||||||
|
exec := &testSDLCExecutor{}
|
||||||
|
_, router := setupSDLCHandler(exec)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(BlockFeatureRequest{Reason: "needs API key"})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features/auth-flow/block", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSDLCHandler_BlockFeature_MissingReason(t *testing.T) {
|
||||||
|
exec := &testSDLCExecutor{}
|
||||||
|
_, router := setupSDLCHandler(exec)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(BlockFeatureRequest{})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features/auth-flow/block", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected status 400, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSDLCHandler_DeleteFeature(t *testing.T) {
|
||||||
|
exec := &testSDLCExecutor{}
|
||||||
|
_, router := setupSDLCHandler(exec)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodDelete, "/projects/test-project/sdlc/features/auth-flow", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNoContent {
|
||||||
|
t.Errorf("expected status 204, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSDLCHandler_DeleteFeature_NotFound(t *testing.T) {
|
||||||
|
exec := &testSDLCExecutor{err: sdlc.ErrFeatureNotFound}
|
||||||
|
_, router := setupSDLCHandler(exec)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodDelete, "/projects/test-project/sdlc/features/nonexistent", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("expected status 404, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSDLCHandler_ApproveArtifact(t *testing.T) {
|
||||||
|
exec := &testSDLCExecutor{}
|
||||||
|
_, router := setupSDLCHandler(exec)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features/auth-flow/artifacts/spec/approve", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSDLCHandler_ApproveArtifact_InvalidType(t *testing.T) {
|
||||||
|
exec := &testSDLCExecutor{}
|
||||||
|
_, router := setupSDLCHandler(exec)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features/auth-flow/artifacts/invalid/approve", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected status 400, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSDLCHandler_AddTask(t *testing.T) {
|
||||||
|
exec := &testSDLCExecutor{}
|
||||||
|
_, router := setupSDLCHandler(exec)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(AddTaskRequest{Title: "Add login form"})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features/auth-flow/tasks", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Errorf("expected status 201, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSDLCHandler_AddTask_MissingTitle(t *testing.T) {
|
||||||
|
exec := &testSDLCExecutor{}
|
||||||
|
_, router := setupSDLCHandler(exec)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(AddTaskRequest{})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features/auth-flow/tasks", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected status 400, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSDLCHandler_QueryBlocked(t *testing.T) {
|
||||||
|
exec := &testSDLCExecutor{
|
||||||
|
blocked: []port.BlockedInfo{
|
||||||
|
{Slug: "auth", Phase: "implementation", Blockers: []string{"needs API key"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, router := setupSDLCHandler(exec)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/projects/test-project/sdlc/query/blocked", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSDLCHandler_InternalError(t *testing.T) {
|
||||||
|
exec := &testSDLCExecutor{err: errors.New("something unexpected")}
|
||||||
|
_, router := setupSDLCHandler(exec)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/projects/test-project/sdlc/state", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusInternalServerError {
|
||||||
|
t.Errorf("expected status 500, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
93
internal/port/sdlc_executor.go
Normal file
93
internal/port/sdlc_executor.go
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
package port
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/orchard9/rdev/internal/sdlc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SDLCExecutor defines operations for executing SDLC commands in project pods.
|
||||||
|
// The adapter runs `sdlc` CLI commands via kubectl exec and parses JSON output.
|
||||||
|
type SDLCExecutor interface {
|
||||||
|
// GetState returns the global SDLC state for a project pod.
|
||||||
|
GetState(ctx context.Context, podName string) (*sdlc.State, error)
|
||||||
|
|
||||||
|
// GetNext returns the classifier's recommendation for the next action.
|
||||||
|
// If feature is empty, the classifier picks the most relevant feature.
|
||||||
|
GetNext(ctx context.Context, podName, feature string) (*sdlc.Classification, error)
|
||||||
|
|
||||||
|
// ListFeatures returns all features in the project.
|
||||||
|
ListFeatures(ctx context.Context, podName string) ([]*sdlc.Feature, error)
|
||||||
|
|
||||||
|
// GetFeature returns a single feature by slug.
|
||||||
|
GetFeature(ctx context.Context, podName, slug string) (*sdlc.Feature, error)
|
||||||
|
|
||||||
|
// CreateFeature creates a new feature with the given slug and title.
|
||||||
|
CreateFeature(ctx context.Context, podName, slug, title string) (*sdlc.Feature, error)
|
||||||
|
|
||||||
|
// TransitionFeature moves a feature to the specified phase.
|
||||||
|
TransitionFeature(ctx context.Context, podName, slug string, phase sdlc.FeaturePhase) error
|
||||||
|
|
||||||
|
// BlockFeature adds a blocker reason to a feature.
|
||||||
|
BlockFeature(ctx context.Context, podName, slug, reason string) error
|
||||||
|
|
||||||
|
// UnblockFeature removes all blockers from a feature.
|
||||||
|
UnblockFeature(ctx context.Context, podName, slug string) error
|
||||||
|
|
||||||
|
// DeleteFeature removes a feature entirely.
|
||||||
|
DeleteFeature(ctx context.Context, podName, slug string) error
|
||||||
|
|
||||||
|
// GetArtifactStatus returns artifact statuses for a feature.
|
||||||
|
GetArtifactStatus(ctx context.Context, podName, slug string) (map[sdlc.ArtifactType]*sdlc.Artifact, error)
|
||||||
|
|
||||||
|
// ApproveArtifact approves a feature artifact.
|
||||||
|
ApproveArtifact(ctx context.Context, podName, slug string, artType sdlc.ArtifactType) error
|
||||||
|
|
||||||
|
// RejectArtifact rejects a feature artifact.
|
||||||
|
RejectArtifact(ctx context.Context, podName, slug string, artType sdlc.ArtifactType) error
|
||||||
|
|
||||||
|
// ListTasks returns all tasks for a feature.
|
||||||
|
ListTasks(ctx context.Context, podName, slug string) ([]sdlc.Task, error)
|
||||||
|
|
||||||
|
// AddTask adds a new task to a feature.
|
||||||
|
AddTask(ctx context.Context, podName, slug, title string) (*sdlc.Task, error)
|
||||||
|
|
||||||
|
// StartTask marks a task as in-progress.
|
||||||
|
StartTask(ctx context.Context, podName, slug, taskID string) error
|
||||||
|
|
||||||
|
// CompleteTask marks a task as complete.
|
||||||
|
CompleteTask(ctx context.Context, podName, slug, taskID string) error
|
||||||
|
|
||||||
|
// BlockTask marks a task as blocked.
|
||||||
|
BlockTask(ctx context.Context, podName, slug, taskID string) error
|
||||||
|
|
||||||
|
// QueryBlocked returns all blocked features.
|
||||||
|
QueryBlocked(ctx context.Context, podName string) ([]BlockedInfo, error)
|
||||||
|
|
||||||
|
// QueryReady returns features ready for work.
|
||||||
|
QueryReady(ctx context.Context, podName string) ([]ReadyInfo, error)
|
||||||
|
|
||||||
|
// QueryNeedsApproval returns features awaiting approval.
|
||||||
|
QueryNeedsApproval(ctx context.Context, podName string) ([]ApprovalInfo, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockedInfo describes a blocked feature (matches sdlc query --json output).
|
||||||
|
type BlockedInfo struct {
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Phase string `json:"phase"`
|
||||||
|
Blockers []string `json:"blockers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadyInfo describes a feature ready for work (matches sdlc query --json output).
|
||||||
|
type ReadyInfo struct {
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Phase string `json:"phase"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApprovalInfo describes a feature awaiting approval (matches sdlc query --json output).
|
||||||
|
type ApprovalInfo struct {
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Phase string `json:"phase"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
63
internal/sdlc/artifact.go
Normal file
63
internal/sdlc/artifact.go
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
package sdlc
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Artifact tracks the status of a feature artifact.
|
||||||
|
type Artifact struct {
|
||||||
|
Status ArtifactStatus `yaml:"status" json:"status"`
|
||||||
|
Path string `yaml:"path" json:"path"`
|
||||||
|
ApprovedBy string `yaml:"approved_by,omitempty" json:"approved_by,omitempty"`
|
||||||
|
ApprovedAt *time.Time `yaml:"approved_at,omitempty" json:"approved_at,omitempty"`
|
||||||
|
RejectedBy string `yaml:"rejected_by,omitempty" json:"rejected_by,omitempty"`
|
||||||
|
RejectedAt *time.Time `yaml:"rejected_at,omitempty" json:"rejected_at,omitempty"`
|
||||||
|
Total int `yaml:"total,omitempty" json:"total,omitempty"`
|
||||||
|
Completed int `yaml:"completed,omitempty" json:"completed,omitempty"`
|
||||||
|
InProgress int `yaml:"in_progress,omitempty" json:"in_progress,omitempty"`
|
||||||
|
Blocked int `yaml:"blocked,omitempty" json:"blocked,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewArtifact creates an artifact in pending status.
|
||||||
|
func NewArtifact(artifactType ArtifactType) *Artifact {
|
||||||
|
return &Artifact{
|
||||||
|
Status: StatusPending,
|
||||||
|
Path: ArtifactFilename(artifactType),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approve marks the artifact as approved.
|
||||||
|
func (a *Artifact) Approve(by string) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
a.Status = StatusApproved
|
||||||
|
a.ApprovedBy = by
|
||||||
|
a.ApprovedAt = &now
|
||||||
|
a.RejectedBy = ""
|
||||||
|
a.RejectedAt = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject marks the artifact as rejected.
|
||||||
|
func (a *Artifact) Reject(by string) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
a.Status = StatusRejected
|
||||||
|
a.RejectedBy = by
|
||||||
|
a.RejectedAt = &now
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkDraft sets the artifact status to draft.
|
||||||
|
func (a *Artifact) MarkDraft() {
|
||||||
|
a.Status = StatusDraft
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkPassed sets the artifact status to passed.
|
||||||
|
func (a *Artifact) MarkPassed() {
|
||||||
|
a.Status = StatusPassed
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkFailed sets the artifact status to failed.
|
||||||
|
func (a *Artifact) MarkFailed() {
|
||||||
|
a.Status = StatusFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkNeedsFix sets the artifact status to needs_fix.
|
||||||
|
func (a *Artifact) MarkNeedsFix() {
|
||||||
|
a.Status = StatusNeedsFix
|
||||||
|
}
|
||||||
91
internal/sdlc/classifier.go
Normal file
91
internal/sdlc/classifier.go
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
package sdlc
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Classification is the output of the classifier engine.
|
||||||
|
type Classification struct {
|
||||||
|
Timestamp time.Time `json:"timestamp" yaml:"timestamp"`
|
||||||
|
Feature string `json:"feature" yaml:"feature"`
|
||||||
|
CurrentPhase FeaturePhase `json:"current_phase" yaml:"current_phase"`
|
||||||
|
RuleMatched string `json:"rule_matched" yaml:"rule_matched"`
|
||||||
|
Action ActionType `json:"action" yaml:"action"`
|
||||||
|
Message string `json:"message" yaml:"message"`
|
||||||
|
NextCommand string `json:"next_command,omitempty" yaml:"next_command,omitempty"`
|
||||||
|
OutputPath string `json:"output_path,omitempty" yaml:"output_path,omitempty"`
|
||||||
|
TransitionTo FeaturePhase `json:"transition_to,omitempty" yaml:"transition_to,omitempty"`
|
||||||
|
TaskID string `json:"task_id,omitempty" yaml:"task_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvalContext provides all the state needed for rule evaluation.
|
||||||
|
type EvalContext struct {
|
||||||
|
State *State
|
||||||
|
Feature *Feature
|
||||||
|
Config *Config
|
||||||
|
Root string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule is a single classifier rule with a condition and resulting action.
|
||||||
|
type Rule struct {
|
||||||
|
ID string
|
||||||
|
Condition func(ctx *EvalContext) bool
|
||||||
|
Action ActionType
|
||||||
|
Message func(ctx *EvalContext) string
|
||||||
|
NextCommand func(ctx *EvalContext) string
|
||||||
|
OutputPath func(ctx *EvalContext) string
|
||||||
|
TransitionTo FeaturePhase
|
||||||
|
TaskID func(ctx *EvalContext) string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Classifier evaluates rules in priority order, returning the first match.
|
||||||
|
type Classifier struct {
|
||||||
|
rules []Rule
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClassifier creates a classifier with the default rules.
|
||||||
|
func NewClassifier() *Classifier {
|
||||||
|
return &Classifier{rules: DefaultRules()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClassifierWithRules creates a classifier with custom rules.
|
||||||
|
func NewClassifierWithRules(rules []Rule) *Classifier {
|
||||||
|
return &Classifier{rules: rules}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Classify evaluates all rules against the context and returns the first match.
|
||||||
|
func (c *Classifier) Classify(ctx *EvalContext) *Classification {
|
||||||
|
for _, rule := range c.rules {
|
||||||
|
if rule.Condition(ctx) {
|
||||||
|
cl := &Classification{
|
||||||
|
Timestamp: time.Now().UTC(),
|
||||||
|
Feature: ctx.Feature.Slug,
|
||||||
|
CurrentPhase: ctx.Feature.Phase,
|
||||||
|
RuleMatched: rule.ID,
|
||||||
|
Action: rule.Action,
|
||||||
|
TransitionTo: rule.TransitionTo,
|
||||||
|
}
|
||||||
|
if rule.Message != nil {
|
||||||
|
cl.Message = rule.Message(ctx)
|
||||||
|
}
|
||||||
|
if rule.NextCommand != nil {
|
||||||
|
cl.NextCommand = rule.NextCommand(ctx)
|
||||||
|
}
|
||||||
|
if rule.OutputPath != nil {
|
||||||
|
cl.OutputPath = rule.OutputPath(ctx)
|
||||||
|
}
|
||||||
|
if rule.TaskID != nil {
|
||||||
|
cl.TaskID = rule.TaskID(ctx)
|
||||||
|
}
|
||||||
|
return cl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: nothing to do
|
||||||
|
return &Classification{
|
||||||
|
Timestamp: time.Now().UTC(),
|
||||||
|
Feature: ctx.Feature.Slug,
|
||||||
|
CurrentPhase: ctx.Feature.Phase,
|
||||||
|
RuleMatched: "nothing-to-do",
|
||||||
|
Action: ActionIdle,
|
||||||
|
Message: "No actionable work found",
|
||||||
|
}
|
||||||
|
}
|
||||||
493
internal/sdlc/classifier_test.go
Normal file
493
internal/sdlc/classifier_test.go
Normal file
@ -0,0 +1,493 @@
|
|||||||
|
package sdlc
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func makeTestFeature(phase FeaturePhase) *Feature {
|
||||||
|
f := &Feature{
|
||||||
|
Slug: "auth",
|
||||||
|
Title: "Auth",
|
||||||
|
Phase: phase,
|
||||||
|
Artifacts: map[ArtifactType]*Artifact{
|
||||||
|
ArtifactSpec: NewArtifact(ArtifactSpec),
|
||||||
|
ArtifactDesign: NewArtifact(ArtifactDesign),
|
||||||
|
ArtifactTasks: NewArtifact(ArtifactTasks),
|
||||||
|
ArtifactQAPlan: NewArtifact(ArtifactQAPlan),
|
||||||
|
ArtifactReview: NewArtifact(ArtifactReview),
|
||||||
|
ArtifactAudit: NewArtifact(ArtifactAudit),
|
||||||
|
ArtifactQAResults: NewArtifact(ArtifactQAResults),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyDraftNeedsSpec(t *testing.T) {
|
||||||
|
c := NewClassifier()
|
||||||
|
f := makeTestFeature(PhaseDraft)
|
||||||
|
|
||||||
|
cl := c.Classify(&EvalContext{
|
||||||
|
State: DefaultState("test"),
|
||||||
|
Feature: f,
|
||||||
|
Config: DefaultConfig("test"),
|
||||||
|
})
|
||||||
|
|
||||||
|
if cl.Action != ActionCreateSpec {
|
||||||
|
t.Errorf("Action = %q, want CREATE_SPEC", cl.Action)
|
||||||
|
}
|
||||||
|
if cl.RuleMatched != "needs-spec" {
|
||||||
|
t.Errorf("RuleMatched = %q, want needs-spec", cl.RuleMatched)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyDraftSpecDraftNeedsApproval(t *testing.T) {
|
||||||
|
c := NewClassifier()
|
||||||
|
f := makeTestFeature(PhaseDraft)
|
||||||
|
f.GetArtifact(ArtifactSpec).MarkDraft()
|
||||||
|
|
||||||
|
cl := c.Classify(&EvalContext{
|
||||||
|
State: DefaultState("test"),
|
||||||
|
Feature: f,
|
||||||
|
Config: DefaultConfig("test"),
|
||||||
|
})
|
||||||
|
|
||||||
|
if cl.Action != ActionAwaitApproval {
|
||||||
|
t.Errorf("Action = %q, want AWAIT_APPROVAL", cl.Action)
|
||||||
|
}
|
||||||
|
if cl.RuleMatched != "spec-needs-approval" {
|
||||||
|
t.Errorf("RuleMatched = %q, want spec-needs-approval", cl.RuleMatched)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyDraftSpecApprovedTransition(t *testing.T) {
|
||||||
|
c := NewClassifier()
|
||||||
|
f := makeTestFeature(PhaseDraft)
|
||||||
|
f.GetArtifact(ArtifactSpec).Approve("user")
|
||||||
|
|
||||||
|
cl := c.Classify(&EvalContext{
|
||||||
|
State: DefaultState("test"),
|
||||||
|
Feature: f,
|
||||||
|
Config: DefaultConfig("test"),
|
||||||
|
})
|
||||||
|
|
||||||
|
if cl.Action != ActionTransition {
|
||||||
|
t.Errorf("Action = %q, want TRANSITION", cl.Action)
|
||||||
|
}
|
||||||
|
if cl.TransitionTo != PhaseSpecified {
|
||||||
|
t.Errorf("TransitionTo = %q, want specified", cl.TransitionTo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifySpecifiedNeedsDesign(t *testing.T) {
|
||||||
|
c := NewClassifier()
|
||||||
|
f := makeTestFeature(PhaseSpecified)
|
||||||
|
|
||||||
|
cl := c.Classify(&EvalContext{
|
||||||
|
State: DefaultState("test"),
|
||||||
|
Feature: f,
|
||||||
|
Config: DefaultConfig("test"),
|
||||||
|
})
|
||||||
|
|
||||||
|
if cl.Action != ActionCreateDesign {
|
||||||
|
t.Errorf("Action = %q, want CREATE_DESIGN", cl.Action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifySpecifiedNeedsTasks(t *testing.T) {
|
||||||
|
c := NewClassifier()
|
||||||
|
f := makeTestFeature(PhaseSpecified)
|
||||||
|
f.GetArtifact(ArtifactDesign).Approve("user")
|
||||||
|
|
||||||
|
cl := c.Classify(&EvalContext{
|
||||||
|
State: DefaultState("test"),
|
||||||
|
Feature: f,
|
||||||
|
Config: DefaultConfig("test"),
|
||||||
|
})
|
||||||
|
|
||||||
|
if cl.Action != ActionCreateTasks {
|
||||||
|
t.Errorf("Action = %q, want CREATE_TASKS", cl.Action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifySpecifiedNeedsQAPlan(t *testing.T) {
|
||||||
|
c := NewClassifier()
|
||||||
|
f := makeTestFeature(PhaseSpecified)
|
||||||
|
f.GetArtifact(ArtifactDesign).Approve("user")
|
||||||
|
f.GetArtifact(ArtifactTasks).Approve("user")
|
||||||
|
|
||||||
|
cl := c.Classify(&EvalContext{
|
||||||
|
State: DefaultState("test"),
|
||||||
|
Feature: f,
|
||||||
|
Config: DefaultConfig("test"),
|
||||||
|
})
|
||||||
|
|
||||||
|
if cl.Action != ActionCreateQAPlan {
|
||||||
|
t.Errorf("Action = %q, want CREATE_QA_PLAN", cl.Action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifySpecifiedPlanningComplete(t *testing.T) {
|
||||||
|
c := NewClassifier()
|
||||||
|
f := makeTestFeature(PhaseSpecified)
|
||||||
|
f.GetArtifact(ArtifactDesign).Approve("user")
|
||||||
|
f.GetArtifact(ArtifactTasks).Approve("user")
|
||||||
|
f.GetArtifact(ArtifactQAPlan).Approve("user")
|
||||||
|
|
||||||
|
cl := c.Classify(&EvalContext{
|
||||||
|
State: DefaultState("test"),
|
||||||
|
Feature: f,
|
||||||
|
Config: DefaultConfig("test"),
|
||||||
|
})
|
||||||
|
|
||||||
|
if cl.Action != ActionTransition {
|
||||||
|
t.Errorf("Action = %q, want TRANSITION", cl.Action)
|
||||||
|
}
|
||||||
|
if cl.TransitionTo != PhasePlanned {
|
||||||
|
t.Errorf("TransitionTo = %q, want planned", cl.TransitionTo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyPlannedTransitionsToReady(t *testing.T) {
|
||||||
|
c := NewClassifier()
|
||||||
|
f := makeTestFeature(PhasePlanned)
|
||||||
|
|
||||||
|
cl := c.Classify(&EvalContext{
|
||||||
|
State: DefaultState("test"),
|
||||||
|
Feature: f,
|
||||||
|
Config: DefaultConfig("test"),
|
||||||
|
})
|
||||||
|
|
||||||
|
if cl.Action != ActionTransition {
|
||||||
|
t.Errorf("Action = %q, want TRANSITION", cl.Action)
|
||||||
|
}
|
||||||
|
if cl.TransitionTo != PhaseReady {
|
||||||
|
t.Errorf("TransitionTo = %q, want ready", cl.TransitionTo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyImplementationNextTask(t *testing.T) {
|
||||||
|
c := NewClassifier()
|
||||||
|
f := makeTestFeature(PhaseImplementation)
|
||||||
|
f.Tasks = AddTask(nil, "Task 1")
|
||||||
|
f.Tasks = AddTask(f.Tasks, "Task 2")
|
||||||
|
|
||||||
|
cl := c.Classify(&EvalContext{
|
||||||
|
State: DefaultState("test"),
|
||||||
|
Feature: f,
|
||||||
|
Config: DefaultConfig("test"),
|
||||||
|
})
|
||||||
|
|
||||||
|
if cl.Action != ActionImplementTask {
|
||||||
|
t.Errorf("Action = %q, want IMPLEMENT_TASK", cl.Action)
|
||||||
|
}
|
||||||
|
if cl.TaskID != "task-001" {
|
||||||
|
t.Errorf("TaskID = %q, want task-001", cl.TaskID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyImplementationComplete(t *testing.T) {
|
||||||
|
c := NewClassifier()
|
||||||
|
f := makeTestFeature(PhaseImplementation)
|
||||||
|
f.Tasks = AddTask(nil, "Task 1")
|
||||||
|
f.Tasks, _ = StartTask(f.Tasks, "task-001")
|
||||||
|
f.Tasks, _ = CompleteTask(f.Tasks, "task-001")
|
||||||
|
|
||||||
|
cl := c.Classify(&EvalContext{
|
||||||
|
State: DefaultState("test"),
|
||||||
|
Feature: f,
|
||||||
|
Config: DefaultConfig("test"),
|
||||||
|
})
|
||||||
|
|
||||||
|
if cl.Action != ActionTransition {
|
||||||
|
t.Errorf("Action = %q, want TRANSITION", cl.Action)
|
||||||
|
}
|
||||||
|
if cl.TransitionTo != PhaseReview {
|
||||||
|
t.Errorf("TransitionTo = %q, want review", cl.TransitionTo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyReviewNeeded(t *testing.T) {
|
||||||
|
c := NewClassifier()
|
||||||
|
f := makeTestFeature(PhaseReview)
|
||||||
|
|
||||||
|
cl := c.Classify(&EvalContext{
|
||||||
|
State: DefaultState("test"),
|
||||||
|
Feature: f,
|
||||||
|
Config: DefaultConfig("test"),
|
||||||
|
})
|
||||||
|
|
||||||
|
if cl.Action != ActionReviewCode {
|
||||||
|
t.Errorf("Action = %q, want REVIEW_CODE", cl.Action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyReviewNeedsFix(t *testing.T) {
|
||||||
|
c := NewClassifier()
|
||||||
|
f := makeTestFeature(PhaseReview)
|
||||||
|
f.GetArtifact(ArtifactReview).MarkNeedsFix()
|
||||||
|
|
||||||
|
cl := c.Classify(&EvalContext{
|
||||||
|
State: DefaultState("test"),
|
||||||
|
Feature: f,
|
||||||
|
Config: DefaultConfig("test"),
|
||||||
|
})
|
||||||
|
|
||||||
|
if cl.Action != ActionFixReviewIssues {
|
||||||
|
t.Errorf("Action = %q, want FIX_REVIEW_ISSUES", cl.Action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyReviewPassed(t *testing.T) {
|
||||||
|
c := NewClassifier()
|
||||||
|
f := makeTestFeature(PhaseReview)
|
||||||
|
f.GetArtifact(ArtifactReview).MarkPassed()
|
||||||
|
|
||||||
|
cl := c.Classify(&EvalContext{
|
||||||
|
State: DefaultState("test"),
|
||||||
|
Feature: f,
|
||||||
|
Config: DefaultConfig("test"),
|
||||||
|
})
|
||||||
|
|
||||||
|
if cl.Action != ActionTransition {
|
||||||
|
t.Errorf("Action = %q, want TRANSITION", cl.Action)
|
||||||
|
}
|
||||||
|
if cl.TransitionTo != PhaseAudit {
|
||||||
|
t.Errorf("TransitionTo = %q, want audit", cl.TransitionTo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyAuditNeeded(t *testing.T) {
|
||||||
|
c := NewClassifier()
|
||||||
|
f := makeTestFeature(PhaseAudit)
|
||||||
|
|
||||||
|
cl := c.Classify(&EvalContext{
|
||||||
|
State: DefaultState("test"),
|
||||||
|
Feature: f,
|
||||||
|
Config: DefaultConfig("test"),
|
||||||
|
})
|
||||||
|
|
||||||
|
if cl.Action != ActionAuditCode {
|
||||||
|
t.Errorf("Action = %q, want AUDIT_CODE", cl.Action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyAuditPassed(t *testing.T) {
|
||||||
|
c := NewClassifier()
|
||||||
|
f := makeTestFeature(PhaseAudit)
|
||||||
|
f.GetArtifact(ArtifactAudit).MarkPassed()
|
||||||
|
|
||||||
|
cl := c.Classify(&EvalContext{
|
||||||
|
State: DefaultState("test"),
|
||||||
|
Feature: f,
|
||||||
|
Config: DefaultConfig("test"),
|
||||||
|
})
|
||||||
|
|
||||||
|
if cl.Action != ActionTransition {
|
||||||
|
t.Errorf("Action = %q, want TRANSITION", cl.Action)
|
||||||
|
}
|
||||||
|
if cl.TransitionTo != PhaseQA {
|
||||||
|
t.Errorf("TransitionTo = %q, want qa", cl.TransitionTo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyQANeeded(t *testing.T) {
|
||||||
|
c := NewClassifier()
|
||||||
|
f := makeTestFeature(PhaseQA)
|
||||||
|
|
||||||
|
cl := c.Classify(&EvalContext{
|
||||||
|
State: DefaultState("test"),
|
||||||
|
Feature: f,
|
||||||
|
Config: DefaultConfig("test"),
|
||||||
|
})
|
||||||
|
|
||||||
|
if cl.Action != ActionRunQA {
|
||||||
|
t.Errorf("Action = %q, want RUN_QA", cl.Action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyQAPassed(t *testing.T) {
|
||||||
|
c := NewClassifier()
|
||||||
|
f := makeTestFeature(PhaseQA)
|
||||||
|
f.GetArtifact(ArtifactQAResults).MarkPassed()
|
||||||
|
|
||||||
|
cl := c.Classify(&EvalContext{
|
||||||
|
State: DefaultState("test"),
|
||||||
|
Feature: f,
|
||||||
|
Config: DefaultConfig("test"),
|
||||||
|
})
|
||||||
|
|
||||||
|
if cl.Action != ActionTransition {
|
||||||
|
t.Errorf("Action = %q, want TRANSITION", cl.Action)
|
||||||
|
}
|
||||||
|
if cl.TransitionTo != PhaseMerge {
|
||||||
|
t.Errorf("TransitionTo = %q, want merge", cl.TransitionTo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyMerge(t *testing.T) {
|
||||||
|
c := NewClassifier()
|
||||||
|
f := makeTestFeature(PhaseMerge)
|
||||||
|
|
||||||
|
cl := c.Classify(&EvalContext{
|
||||||
|
State: DefaultState("test"),
|
||||||
|
Feature: f,
|
||||||
|
Config: DefaultConfig("test"),
|
||||||
|
})
|
||||||
|
|
||||||
|
if cl.Action != ActionMergeFeature {
|
||||||
|
t.Errorf("Action = %q, want MERGE_FEATURE", cl.Action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyArchive(t *testing.T) {
|
||||||
|
c := NewClassifier()
|
||||||
|
f := makeTestFeature(PhaseReleased)
|
||||||
|
|
||||||
|
cl := c.Classify(&EvalContext{
|
||||||
|
State: DefaultState("test"),
|
||||||
|
Feature: f,
|
||||||
|
Config: DefaultConfig("test"),
|
||||||
|
})
|
||||||
|
|
||||||
|
if cl.Action != ActionArchive {
|
||||||
|
t.Errorf("Action = %q, want ARCHIVE", cl.Action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyBlocked(t *testing.T) {
|
||||||
|
c := NewClassifier()
|
||||||
|
f := makeTestFeature(PhaseDraft)
|
||||||
|
f.AddBlocker("depends on payments")
|
||||||
|
|
||||||
|
cl := c.Classify(&EvalContext{
|
||||||
|
State: DefaultState("test"),
|
||||||
|
Feature: f,
|
||||||
|
Config: DefaultConfig("test"),
|
||||||
|
})
|
||||||
|
|
||||||
|
if cl.Action != ActionBlocked {
|
||||||
|
t.Errorf("Action = %q, want BLOCKED", cl.Action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFullLifecycleClassification walks through the entire feature lifecycle.
|
||||||
|
func TestFullLifecycleClassification(t *testing.T) {
|
||||||
|
c := NewClassifier()
|
||||||
|
cfg := DefaultConfig("test")
|
||||||
|
state := DefaultState("test")
|
||||||
|
f := makeTestFeature(PhaseDraft)
|
||||||
|
|
||||||
|
// Phase: Draft
|
||||||
|
// Step 1: needs spec
|
||||||
|
cl := c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
||||||
|
if cl.Action != ActionCreateSpec {
|
||||||
|
t.Fatalf("step1: Action = %q, want CREATE_SPEC", cl.Action)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: spec created -> needs approval
|
||||||
|
f.GetArtifact(ArtifactSpec).MarkDraft()
|
||||||
|
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
||||||
|
if cl.Action != ActionAwaitApproval {
|
||||||
|
t.Fatalf("step2: Action = %q, want AWAIT_APPROVAL", cl.Action)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: spec approved -> transition to specified
|
||||||
|
f.GetArtifact(ArtifactSpec).Approve("user")
|
||||||
|
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
||||||
|
if cl.Action != ActionTransition || cl.TransitionTo != PhaseSpecified {
|
||||||
|
t.Fatalf("step3: Action = %q/%q", cl.Action, cl.TransitionTo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase: Specified
|
||||||
|
f.Transition(PhaseSpecified)
|
||||||
|
|
||||||
|
// Step 4: needs design
|
||||||
|
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
||||||
|
if cl.Action != ActionCreateDesign {
|
||||||
|
t.Fatalf("step4: Action = %q, want CREATE_DESIGN", cl.Action)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: design approved -> needs tasks
|
||||||
|
f.GetArtifact(ArtifactDesign).Approve("user")
|
||||||
|
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
||||||
|
if cl.Action != ActionCreateTasks {
|
||||||
|
t.Fatalf("step5: Action = %q, want CREATE_TASKS", cl.Action)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 6: tasks approved -> needs qa plan
|
||||||
|
f.GetArtifact(ArtifactTasks).Approve("user")
|
||||||
|
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
||||||
|
if cl.Action != ActionCreateQAPlan {
|
||||||
|
t.Fatalf("step6: Action = %q, want CREATE_QA_PLAN", cl.Action)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 7: qa plan approved -> transition to planned
|
||||||
|
f.GetArtifact(ArtifactQAPlan).Approve("user")
|
||||||
|
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
||||||
|
if cl.Action != ActionTransition || cl.TransitionTo != PhasePlanned {
|
||||||
|
t.Fatalf("step7: Action = %q/%q", cl.Action, cl.TransitionTo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase: Planned -> Ready -> Implementation
|
||||||
|
f.Transition(PhasePlanned)
|
||||||
|
f.Transition(PhaseReady)
|
||||||
|
f.Transition(PhaseImplementation)
|
||||||
|
|
||||||
|
// Add tasks
|
||||||
|
f.Tasks = AddTask(nil, "Create user model")
|
||||||
|
f.Tasks = AddTask(f.Tasks, "Add validation")
|
||||||
|
|
||||||
|
// Step 8: implement next task
|
||||||
|
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
||||||
|
if cl.Action != ActionImplementTask {
|
||||||
|
t.Fatalf("step8: Action = %q, want IMPLEMENT_TASK", cl.Action)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete all tasks
|
||||||
|
f.Tasks, _ = StartTask(f.Tasks, "task-001")
|
||||||
|
f.Tasks, _ = CompleteTask(f.Tasks, "task-001")
|
||||||
|
f.Tasks, _ = StartTask(f.Tasks, "task-002")
|
||||||
|
f.Tasks, _ = CompleteTask(f.Tasks, "task-002")
|
||||||
|
|
||||||
|
// Step 9: implementation complete -> transition to review
|
||||||
|
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
||||||
|
if cl.Action != ActionTransition || cl.TransitionTo != PhaseReview {
|
||||||
|
t.Fatalf("step9: Action = %q/%q", cl.Action, cl.TransitionTo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase: Review
|
||||||
|
f.Transition(PhaseReview)
|
||||||
|
f.GetArtifact(ArtifactReview).MarkPassed()
|
||||||
|
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
||||||
|
if cl.TransitionTo != PhaseAudit {
|
||||||
|
t.Fatalf("review->audit: TransitionTo = %q", cl.TransitionTo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase: Audit
|
||||||
|
f.Transition(PhaseAudit)
|
||||||
|
f.GetArtifact(ArtifactAudit).MarkPassed()
|
||||||
|
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
||||||
|
if cl.TransitionTo != PhaseQA {
|
||||||
|
t.Fatalf("audit->qa: TransitionTo = %q", cl.TransitionTo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase: QA
|
||||||
|
f.Transition(PhaseQA)
|
||||||
|
f.GetArtifact(ArtifactQAResults).MarkPassed()
|
||||||
|
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
||||||
|
if cl.TransitionTo != PhaseMerge {
|
||||||
|
t.Fatalf("qa->merge: TransitionTo = %q", cl.TransitionTo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase: Merge
|
||||||
|
f.Transition(PhaseMerge)
|
||||||
|
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
||||||
|
if cl.Action != ActionMergeFeature {
|
||||||
|
t.Fatalf("merge: Action = %q, want MERGE_FEATURE", cl.Action)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase: Released
|
||||||
|
f.Transition(PhaseReleased)
|
||||||
|
cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg})
|
||||||
|
if cl.Action != ActionArchive {
|
||||||
|
t.Fatalf("released: Action = %q, want ARCHIVE", cl.Action)
|
||||||
|
}
|
||||||
|
}
|
||||||
115
internal/sdlc/config.go
Normal file
115
internal/sdlc/config.go
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
package sdlc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config represents the project SDLC configuration in .sdlc/config.yaml.
|
||||||
|
type Config struct {
|
||||||
|
Version int `yaml:"version" json:"version"`
|
||||||
|
Project ProjectConfig `yaml:"project" json:"project"`
|
||||||
|
Branches BranchConfig `yaml:"branches" json:"branches"`
|
||||||
|
Phases PhaseConfig `yaml:"phases" json:"phases"`
|
||||||
|
Compliance ComplianceConfig `yaml:"compliance" json:"compliance"`
|
||||||
|
Patterns PatternsConfig `yaml:"patterns,omitempty" json:"patterns,omitempty"` //nolint:omitzero
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectConfig holds project-level settings.
|
||||||
|
type ProjectConfig struct {
|
||||||
|
Name string `yaml:"name" json:"name"`
|
||||||
|
Type string `yaml:"type,omitempty" json:"type,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BranchConfig defines branch naming conventions.
|
||||||
|
type BranchConfig struct {
|
||||||
|
Main string `yaml:"main" json:"main"`
|
||||||
|
FeaturePrefix string `yaml:"feature_prefix" json:"feature_prefix"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PhaseConfig defines which phases are enabled and what artifacts are required.
|
||||||
|
type PhaseConfig struct {
|
||||||
|
Enabled []FeaturePhase `yaml:"enabled" json:"enabled"`
|
||||||
|
RequiredArtifacts map[FeaturePhase][]ArtifactType `yaml:"required_artifacts" json:"required_artifacts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComplianceConfig defines approval and gate requirements.
|
||||||
|
type ComplianceConfig struct {
|
||||||
|
RequireApprovals bool `yaml:"require_approvals" json:"require_approvals"`
|
||||||
|
RequireBranch bool `yaml:"require_branch" json:"require_branch"`
|
||||||
|
RequireQA bool `yaml:"require_qa" json:"require_qa"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PatternsConfig defines pattern enforcement.
|
||||||
|
type PatternsConfig struct {
|
||||||
|
AutoEnforce []string `yaml:"auto_enforce,omitempty" json:"auto_enforce,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadConfig reads and parses .sdlc/config.yaml from the given project root.
|
||||||
|
func LoadConfig(root string) (*Config, error) {
|
||||||
|
path := ConfigPath(root)
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, ErrNotInitialized
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("read config file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var c Config
|
||||||
|
if err := yaml.Unmarshal(data, &c); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse config file: %w", err)
|
||||||
|
}
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save writes the config to .sdlc/config.yaml.
|
||||||
|
func (c *Config) Save(root string) error {
|
||||||
|
data, err := yaml.Marshal(c)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
path := ConfigPath(root)
|
||||||
|
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||||
|
return fmt.Errorf("write config file: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultConfig returns a config with all phases enabled and standard requirements.
|
||||||
|
func DefaultConfig(projectName string) *Config {
|
||||||
|
return &Config{
|
||||||
|
Version: 1,
|
||||||
|
Project: ProjectConfig{
|
||||||
|
Name: projectName,
|
||||||
|
},
|
||||||
|
Branches: BranchConfig{
|
||||||
|
Main: "main",
|
||||||
|
FeaturePrefix: "feature/",
|
||||||
|
},
|
||||||
|
Phases: PhaseConfig{
|
||||||
|
Enabled: ValidPhases,
|
||||||
|
RequiredArtifacts: map[FeaturePhase][]ArtifactType{
|
||||||
|
PhaseSpecified: {ArtifactSpec},
|
||||||
|
PhasePlanned: {ArtifactSpec, ArtifactDesign, ArtifactTasks, ArtifactQAPlan},
|
||||||
|
PhaseReview: {ArtifactReview},
|
||||||
|
PhaseAudit: {ArtifactAudit},
|
||||||
|
PhaseQA: {ArtifactQAResults},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Compliance: ComplianceConfig{
|
||||||
|
RequireApprovals: true,
|
||||||
|
RequireBranch: true,
|
||||||
|
RequireQA: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsPhaseEnabled returns true if the phase is in the enabled list.
|
||||||
|
func (c *Config) IsPhaseEnabled(phase FeaturePhase) bool {
|
||||||
|
return slices.Contains(c.Phases.Enabled, phase)
|
||||||
|
}
|
||||||
76
internal/sdlc/config_test.go
Normal file
76
internal/sdlc/config_test.go
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
package sdlc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConfigRoundTrip(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
|
||||||
|
if err := os.MkdirAll(SDLCRoot(root), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
original := DefaultConfig("test-project")
|
||||||
|
|
||||||
|
if err := original.Save(root); err != nil {
|
||||||
|
t.Fatalf("Save: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded, err := LoadConfig(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if loaded.Version != 1 {
|
||||||
|
t.Errorf("Version = %d, want 1", loaded.Version)
|
||||||
|
}
|
||||||
|
if loaded.Project.Name != "test-project" {
|
||||||
|
t.Errorf("Project.Name = %q, want %q", loaded.Project.Name, "test-project")
|
||||||
|
}
|
||||||
|
if loaded.Branches.Main != "main" {
|
||||||
|
t.Errorf("Branches.Main = %q, want main", loaded.Branches.Main)
|
||||||
|
}
|
||||||
|
if loaded.Branches.FeaturePrefix != "feature/" {
|
||||||
|
t.Errorf("Branches.FeaturePrefix = %q, want feature/", loaded.Branches.FeaturePrefix)
|
||||||
|
}
|
||||||
|
if len(loaded.Phases.Enabled) != len(ValidPhases) {
|
||||||
|
t.Errorf("Phases.Enabled len = %d, want %d", len(loaded.Phases.Enabled), len(ValidPhases))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultConfigRequiredArtifacts(t *testing.T) {
|
||||||
|
c := DefaultConfig("test")
|
||||||
|
|
||||||
|
// specified phase requires spec
|
||||||
|
arts, ok := c.Phases.RequiredArtifacts[PhaseSpecified]
|
||||||
|
if !ok || len(arts) != 1 || arts[0] != ArtifactSpec {
|
||||||
|
t.Errorf("RequiredArtifacts[specified] = %v, want [spec]", arts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// planned phase requires 4 artifacts
|
||||||
|
arts, ok = c.Phases.RequiredArtifacts[PhasePlanned]
|
||||||
|
if !ok || len(arts) != 4 {
|
||||||
|
t.Errorf("RequiredArtifacts[planned] len = %d, want 4", len(arts))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsPhaseEnabled(t *testing.T) {
|
||||||
|
c := DefaultConfig("test")
|
||||||
|
|
||||||
|
if !c.IsPhaseEnabled(PhaseDraft) {
|
||||||
|
t.Error("IsPhaseEnabled(draft) = false, want true")
|
||||||
|
}
|
||||||
|
if c.IsPhaseEnabled("bogus") {
|
||||||
|
t.Error("IsPhaseEnabled(bogus) = true, want false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadConfigNotInitialized(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
_, err := LoadConfig(root)
|
||||||
|
if err != ErrNotInitialized {
|
||||||
|
t.Errorf("LoadConfig = %v, want ErrNotInitialized", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
16
internal/sdlc/errors.go
Normal file
16
internal/sdlc/errors.go
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
package sdlc
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNotInitialized = errors.New("sdlc not initialized: run 'sdlc init'")
|
||||||
|
ErrFeatureNotFound = errors.New("feature not found")
|
||||||
|
ErrFeatureExists = errors.New("feature already exists")
|
||||||
|
ErrInvalidSlug = errors.New("invalid slug: must be lowercase alphanumeric with hyphens")
|
||||||
|
ErrInvalidTransition = errors.New("invalid phase transition")
|
||||||
|
ErrInvalidPhase = errors.New("invalid phase")
|
||||||
|
ErrInvalidArtifact = errors.New("invalid artifact type")
|
||||||
|
ErrTaskNotFound = errors.New("task not found")
|
||||||
|
ErrArtifactNotFound = errors.New("artifact not found")
|
||||||
|
ErrNoFeatures = errors.New("no features found")
|
||||||
|
)
|
||||||
267
internal/sdlc/feature.go
Normal file
267
internal/sdlc/feature.go
Normal file
@ -0,0 +1,267 @@
|
|||||||
|
package sdlc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Feature represents a feature under development, stored in manifest.yaml.
|
||||||
|
type Feature struct {
|
||||||
|
Slug string `yaml:"slug" json:"slug"`
|
||||||
|
Title string `yaml:"title" json:"title"`
|
||||||
|
Created time.Time `yaml:"created" json:"created"`
|
||||||
|
Branch string `yaml:"branch,omitempty" json:"branch,omitempty"`
|
||||||
|
RoadmapRef string `yaml:"roadmap_ref,omitempty" json:"roadmap_ref,omitempty"`
|
||||||
|
Phase FeaturePhase `yaml:"phase" json:"phase"`
|
||||||
|
PhaseHistory []PhaseTransition `yaml:"phase_history" json:"phase_history"`
|
||||||
|
Artifacts map[ArtifactType]*Artifact `yaml:"artifacts" json:"artifacts"`
|
||||||
|
Tasks []Task `yaml:"tasks,omitempty" json:"tasks,omitempty"`
|
||||||
|
Blockers []string `yaml:"blockers,omitempty" json:"blockers,omitempty"`
|
||||||
|
Dependencies Dependencies `yaml:"dependencies,omitempty" json:"dependencies,omitempty"` //nolint:omitzero
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateFeature creates a new feature directory and manifest.
|
||||||
|
func CreateFeature(root, slug, title string) (*Feature, error) {
|
||||||
|
if err := ValidateSlug(slug); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := FeatureDir(root, slug)
|
||||||
|
if _, err := os.Stat(dir); err == nil {
|
||||||
|
return nil, ErrFeatureExists
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return nil, fmt.Errorf("create feature directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
f := &Feature{
|
||||||
|
Slug: slug,
|
||||||
|
Title: title,
|
||||||
|
Created: now,
|
||||||
|
Phase: PhaseDraft,
|
||||||
|
PhaseHistory: []PhaseTransition{
|
||||||
|
{Phase: PhaseDraft, Entered: now},
|
||||||
|
},
|
||||||
|
Artifacts: map[ArtifactType]*Artifact{
|
||||||
|
ArtifactSpec: NewArtifact(ArtifactSpec),
|
||||||
|
ArtifactDesign: NewArtifact(ArtifactDesign),
|
||||||
|
ArtifactTasks: NewArtifact(ArtifactTasks),
|
||||||
|
ArtifactQAPlan: NewArtifact(ArtifactQAPlan),
|
||||||
|
ArtifactReview: NewArtifact(ArtifactReview),
|
||||||
|
ArtifactAudit: NewArtifact(ArtifactAudit),
|
||||||
|
ArtifactQAResults: NewArtifact(ArtifactQAResults),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := f.Save(root); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadFeature reads a feature manifest from disk.
|
||||||
|
func LoadFeature(root, slug string) (*Feature, error) {
|
||||||
|
path := ManifestPath(root, slug)
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, ErrFeatureNotFound
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("read feature manifest: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var f Feature
|
||||||
|
if err := yaml.Unmarshal(data, &f); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse feature manifest: %w", err)
|
||||||
|
}
|
||||||
|
return &f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save writes the feature manifest to disk.
|
||||||
|
func (f *Feature) Save(root string) error {
|
||||||
|
data, err := yaml.Marshal(f)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal feature manifest: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
path := ManifestPath(root, f.Slug)
|
||||||
|
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||||
|
return fmt.Errorf("write feature manifest: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListFeatures returns all features found in .sdlc/features/.
|
||||||
|
func ListFeatures(root string) ([]*Feature, error) {
|
||||||
|
dir := FeaturesDirPath(root)
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, ErrNotInitialized
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("read features directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var features []*Feature
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
manifestPath := filepath.Join(dir, entry.Name(), ManifestFile)
|
||||||
|
if _, err := os.Stat(manifestPath); os.IsNotExist(err) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
f, err := LoadFeature(root, entry.Name())
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
features = append(features, f)
|
||||||
|
}
|
||||||
|
return features, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanTransitionTo checks if the feature can move to the target phase.
|
||||||
|
// It validates ordering, config allowances, required artifacts, and blockers.
|
||||||
|
func (f *Feature) CanTransitionTo(target FeaturePhase, cfg *Config) error {
|
||||||
|
if !IsValidPhase(target) {
|
||||||
|
return fmt.Errorf("%w: %s is not a valid phase", ErrInvalidPhase, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg != nil && !cfg.IsPhaseEnabled(target) {
|
||||||
|
return fmt.Errorf("%w: phase %s is not enabled", ErrInvalidTransition, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentIdx := PhaseIndex(f.Phase)
|
||||||
|
targetIdx := PhaseIndex(target)
|
||||||
|
|
||||||
|
if targetIdx <= currentIdx {
|
||||||
|
return fmt.Errorf("%w: cannot move from %s to %s (backward)", ErrInvalidTransition, f.Phase, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allow moving to the next phase (no skipping)
|
||||||
|
if targetIdx != currentIdx+1 {
|
||||||
|
return fmt.Errorf("%w: cannot skip from %s to %s", ErrInvalidTransition, f.Phase, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check required artifacts for the target phase are approved/passed
|
||||||
|
if cfg != nil {
|
||||||
|
required, ok := cfg.Phases.RequiredArtifacts[target]
|
||||||
|
if ok {
|
||||||
|
for _, artType := range required {
|
||||||
|
art, exists := f.Artifacts[artType]
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("%w: missing required artifact %s to enter phase %s", ErrInvalidTransition, artType, target)
|
||||||
|
}
|
||||||
|
if art.Status != StatusApproved && art.Status != StatusPassed {
|
||||||
|
return fmt.Errorf("%w: artifact %s is %s, must be approved or passed to enter %s", ErrInvalidTransition, artType, art.Status, target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check no blockers
|
||||||
|
if len(f.Blockers) > 0 {
|
||||||
|
return fmt.Errorf("%w: feature has %d blocker(s)", ErrInvalidTransition, len(f.Blockers))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transition moves the feature to the target phase, recording history.
|
||||||
|
func (f *Feature) Transition(target FeaturePhase) error {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
// Close the current phase history entry
|
||||||
|
if len(f.PhaseHistory) > 0 {
|
||||||
|
f.PhaseHistory[len(f.PhaseHistory)-1].Exited = &now
|
||||||
|
}
|
||||||
|
|
||||||
|
f.Phase = target
|
||||||
|
f.PhaseHistory = append(f.PhaseHistory, PhaseTransition{
|
||||||
|
Phase: target,
|
||||||
|
Entered: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddBlocker adds a blocker reason.
|
||||||
|
func (f *Feature) AddBlocker(reason string) {
|
||||||
|
f.Blockers = append(f.Blockers, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearBlockers removes all blockers.
|
||||||
|
func (f *Feature) ClearBlockers() {
|
||||||
|
f.Blockers = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsBlocked returns true if the feature has any blockers.
|
||||||
|
func (f *Feature) IsBlocked() bool {
|
||||||
|
return len(f.Blockers) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetArtifact returns the artifact by type, or nil.
|
||||||
|
func (f *Feature) GetArtifact(t ArtifactType) *Artifact {
|
||||||
|
if f.Artifacts == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return f.Artifacts[t]
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetArtifact sets or creates an artifact entry.
|
||||||
|
func (f *Feature) SetArtifact(t ArtifactType, a *Artifact) {
|
||||||
|
if f.Artifacts == nil {
|
||||||
|
f.Artifacts = make(map[ArtifactType]*Artifact)
|
||||||
|
}
|
||||||
|
f.Artifacts[t] = a
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArtifactFileExists checks if the artifact file exists on disk.
|
||||||
|
func (f *Feature) ArtifactFileExists(root string, artType ArtifactType) bool {
|
||||||
|
path := ArtifactPath(root, f.Slug, artType)
|
||||||
|
if path == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteFeature removes a feature directory and all contents.
|
||||||
|
func DeleteFeature(root, slug string) error {
|
||||||
|
dir := FeatureDir(root, slug)
|
||||||
|
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||||
|
return ErrFeatureNotFound
|
||||||
|
}
|
||||||
|
return os.RemoveAll(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArchiveFeature moves a feature from features/ to archives/.
|
||||||
|
func ArchiveFeature(root, slug string) error {
|
||||||
|
src := FeatureDir(root, slug)
|
||||||
|
if _, err := os.Stat(src); os.IsNotExist(err) {
|
||||||
|
return ErrFeatureNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
dst := filepath.Join(SDLCRoot(root), ArchivesDir, slug)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||||
|
return fmt.Errorf("create archives directory: %w", err)
|
||||||
|
}
|
||||||
|
return os.Rename(src, dst)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTaskSummary refreshes the tasks artifact with current counts.
|
||||||
|
func (f *Feature) UpdateTaskSummary() {
|
||||||
|
summary := SummarizeTasks(f.Tasks)
|
||||||
|
if art := f.GetArtifact(ArtifactTasks); art != nil {
|
||||||
|
art.Total = summary.Total
|
||||||
|
art.Completed = summary.Completed
|
||||||
|
art.InProgress = summary.InProgress
|
||||||
|
art.Blocked = summary.Blocked
|
||||||
|
}
|
||||||
|
}
|
||||||
313
internal/sdlc/feature_test.go
Normal file
313
internal/sdlc/feature_test.go
Normal file
@ -0,0 +1,313 @@
|
|||||||
|
package sdlc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupInitializedRoot(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
root := t.TempDir()
|
||||||
|
if err := Init(root, "test-project"); err != nil {
|
||||||
|
t.Fatalf("Init: %v", err)
|
||||||
|
}
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateFeature(t *testing.T) {
|
||||||
|
root := setupInitializedRoot(t)
|
||||||
|
|
||||||
|
f, err := CreateFeature(root, "auth", "User Authentication")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateFeature: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.Slug != "auth" {
|
||||||
|
t.Errorf("Slug = %q, want auth", f.Slug)
|
||||||
|
}
|
||||||
|
if f.Title != "User Authentication" {
|
||||||
|
t.Errorf("Title = %q, want User Authentication", f.Title)
|
||||||
|
}
|
||||||
|
if f.Phase != PhaseDraft {
|
||||||
|
t.Errorf("Phase = %q, want draft", f.Phase)
|
||||||
|
}
|
||||||
|
if len(f.PhaseHistory) != 1 {
|
||||||
|
t.Errorf("PhaseHistory len = %d, want 1", len(f.PhaseHistory))
|
||||||
|
}
|
||||||
|
if len(f.Artifacts) != 7 {
|
||||||
|
t.Errorf("Artifacts len = %d, want 7", len(f.Artifacts))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateFeatureDuplicate(t *testing.T) {
|
||||||
|
root := setupInitializedRoot(t)
|
||||||
|
|
||||||
|
if _, err := CreateFeature(root, "auth", "Auth"); err != nil {
|
||||||
|
t.Fatalf("CreateFeature: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := CreateFeature(root, "auth", "Auth Again")
|
||||||
|
if !errors.Is(err, ErrFeatureExists) {
|
||||||
|
t.Errorf("err = %v, want ErrFeatureExists", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateFeatureInvalidSlug(t *testing.T) {
|
||||||
|
root := setupInitializedRoot(t)
|
||||||
|
|
||||||
|
_, err := CreateFeature(root, "INVALID", "Bad Slug")
|
||||||
|
if !errors.Is(err, ErrInvalidSlug) {
|
||||||
|
t.Errorf("err = %v, want ErrInvalidSlug", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadFeature(t *testing.T) {
|
||||||
|
root := setupInitializedRoot(t)
|
||||||
|
|
||||||
|
if _, err := CreateFeature(root, "auth", "Auth"); err != nil {
|
||||||
|
t.Fatalf("CreateFeature: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded, err := LoadFeature(root, "auth")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadFeature: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if loaded.Slug != "auth" {
|
||||||
|
t.Errorf("Slug = %q, want auth", loaded.Slug)
|
||||||
|
}
|
||||||
|
if loaded.Phase != PhaseDraft {
|
||||||
|
t.Errorf("Phase = %q, want draft", loaded.Phase)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadFeatureNotFound(t *testing.T) {
|
||||||
|
root := setupInitializedRoot(t)
|
||||||
|
|
||||||
|
_, err := LoadFeature(root, "nonexistent")
|
||||||
|
if !errors.Is(err, ErrFeatureNotFound) {
|
||||||
|
t.Errorf("err = %v, want ErrFeatureNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListFeatures(t *testing.T) {
|
||||||
|
root := setupInitializedRoot(t)
|
||||||
|
|
||||||
|
if _, err := CreateFeature(root, "auth", "Auth"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := CreateFeature(root, "payments", "Payments"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
features, err := ListFeatures(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListFeatures: %v", err)
|
||||||
|
}
|
||||||
|
if len(features) != 2 {
|
||||||
|
t.Errorf("ListFeatures len = %d, want 2", len(features))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCanTransitionTo(t *testing.T) {
|
||||||
|
cfg := DefaultConfig("test")
|
||||||
|
f := &Feature{
|
||||||
|
Phase: PhaseDraft,
|
||||||
|
Artifacts: map[ArtifactType]*Artifact{
|
||||||
|
ArtifactSpec: {Status: StatusApproved},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid: draft -> specified (spec is approved)
|
||||||
|
if err := f.CanTransitionTo(PhaseSpecified, cfg); err != nil {
|
||||||
|
t.Errorf("CanTransitionTo(specified) = %v, want nil", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalid: draft -> planned (skip)
|
||||||
|
if err := f.CanTransitionTo(PhasePlanned, cfg); err == nil {
|
||||||
|
t.Error("CanTransitionTo(planned) = nil, want error (skip)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalid: draft -> draft (backward)
|
||||||
|
if err := f.CanTransitionTo(PhaseDraft, cfg); err == nil {
|
||||||
|
t.Error("CanTransitionTo(draft) = nil, want error (backward)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalid phase
|
||||||
|
if err := f.CanTransitionTo("bogus", cfg); err == nil {
|
||||||
|
t.Error("CanTransitionTo(bogus) = nil, want error")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without config, artifact checks are skipped
|
||||||
|
bare := &Feature{Phase: PhaseDraft}
|
||||||
|
if err := bare.CanTransitionTo(PhaseSpecified, nil); err != nil {
|
||||||
|
t.Errorf("CanTransitionTo(specified) without config = %v, want nil", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransition(t *testing.T) {
|
||||||
|
f := &Feature{
|
||||||
|
Phase: PhaseDraft,
|
||||||
|
PhaseHistory: []PhaseTransition{
|
||||||
|
{Phase: PhaseDraft},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := f.Transition(PhaseSpecified); err != nil {
|
||||||
|
t.Fatalf("Transition: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.Phase != PhaseSpecified {
|
||||||
|
t.Errorf("Phase = %q, want specified", f.Phase)
|
||||||
|
}
|
||||||
|
if len(f.PhaseHistory) != 2 {
|
||||||
|
t.Fatalf("PhaseHistory len = %d, want 2", len(f.PhaseHistory))
|
||||||
|
}
|
||||||
|
if f.PhaseHistory[0].Exited == nil {
|
||||||
|
t.Error("PhaseHistory[0].Exited is nil, want set")
|
||||||
|
}
|
||||||
|
if f.PhaseHistory[1].Phase != PhaseSpecified {
|
||||||
|
t.Errorf("PhaseHistory[1].Phase = %q, want specified", f.PhaseHistory[1].Phase)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFeatureManifestRoundTrip(t *testing.T) {
|
||||||
|
root := setupInitializedRoot(t)
|
||||||
|
|
||||||
|
f, err := CreateFeature(root, "auth", "Auth")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modify and save
|
||||||
|
f.Branch = "feature/auth"
|
||||||
|
if err := f.Transition(PhaseSpecified); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f.GetArtifact(ArtifactSpec).Approve("user")
|
||||||
|
f.Tasks = AddTask(nil, "Create user model")
|
||||||
|
f.UpdateTaskSummary()
|
||||||
|
|
||||||
|
if err := f.Save(root); err != nil {
|
||||||
|
t.Fatalf("Save: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded, err := LoadFeature(root, "auth")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadFeature: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if loaded.Branch != "feature/auth" {
|
||||||
|
t.Errorf("Branch = %q, want feature/auth", loaded.Branch)
|
||||||
|
}
|
||||||
|
if loaded.Phase != PhaseSpecified {
|
||||||
|
t.Errorf("Phase = %q, want specified", loaded.Phase)
|
||||||
|
}
|
||||||
|
if loaded.GetArtifact(ArtifactSpec).Status != StatusApproved {
|
||||||
|
t.Errorf("Spec status = %q, want approved", loaded.GetArtifact(ArtifactSpec).Status)
|
||||||
|
}
|
||||||
|
if len(loaded.Tasks) != 1 {
|
||||||
|
t.Errorf("Tasks len = %d, want 1", len(loaded.Tasks))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCanTransitionToRequiredArtifacts(t *testing.T) {
|
||||||
|
cfg := DefaultConfig("test")
|
||||||
|
|
||||||
|
// draft -> specified requires spec to be approved
|
||||||
|
f := &Feature{
|
||||||
|
Phase: PhaseDraft,
|
||||||
|
Artifacts: map[ArtifactType]*Artifact{
|
||||||
|
ArtifactSpec: {Status: StatusPending},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := f.CanTransitionTo(PhaseSpecified, cfg)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("CanTransitionTo(specified) should fail with unapproved spec")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approve spec - now draft -> specified should work
|
||||||
|
f.Artifacts[ArtifactSpec].Approve("user")
|
||||||
|
err = f.CanTransitionTo(PhaseSpecified, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("CanTransitionTo(specified) with approved spec: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// specified -> planned requires spec, design, tasks, qa_plan
|
||||||
|
f.Phase = PhaseSpecified
|
||||||
|
err = f.CanTransitionTo(PhasePlanned, cfg)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("CanTransitionTo(planned) should fail with missing design/tasks/qa_plan")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCanTransitionToBlockersPrevent(t *testing.T) {
|
||||||
|
cfg := DefaultConfig("test")
|
||||||
|
|
||||||
|
f := &Feature{
|
||||||
|
Phase: PhaseDraft,
|
||||||
|
Blockers: []string{"dependency on payments"},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := f.CanTransitionTo(PhaseSpecified, cfg)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("CanTransitionTo should fail when feature has blockers")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteFeature(t *testing.T) {
|
||||||
|
root := setupInitializedRoot(t)
|
||||||
|
|
||||||
|
if _, err := CreateFeature(root, "auth", "Auth"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := DeleteFeature(root, "auth"); err != nil {
|
||||||
|
t.Fatalf("DeleteFeature: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := LoadFeature(root, "auth")
|
||||||
|
if err != ErrFeatureNotFound {
|
||||||
|
t.Errorf("LoadFeature after delete: %v, want ErrFeatureNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteFeatureNotFound(t *testing.T) {
|
||||||
|
root := setupInitializedRoot(t)
|
||||||
|
if err := DeleteFeature(root, "nonexistent"); err != ErrFeatureNotFound {
|
||||||
|
t.Errorf("DeleteFeature = %v, want ErrFeatureNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArtifactFileExists(t *testing.T) {
|
||||||
|
root := setupInitializedRoot(t)
|
||||||
|
|
||||||
|
f, err := CreateFeature(root, "auth", "Auth")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No spec file exists yet
|
||||||
|
if f.ArtifactFileExists(root, ArtifactSpec) {
|
||||||
|
t.Error("ArtifactFileExists(spec) = true before file creation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlockers(t *testing.T) {
|
||||||
|
f := &Feature{}
|
||||||
|
|
||||||
|
if f.IsBlocked() {
|
||||||
|
t.Error("IsBlocked = true, want false")
|
||||||
|
}
|
||||||
|
|
||||||
|
f.AddBlocker("dependency on auth")
|
||||||
|
if !f.IsBlocked() {
|
||||||
|
t.Error("IsBlocked = false, want true")
|
||||||
|
}
|
||||||
|
|
||||||
|
f.ClearBlockers()
|
||||||
|
if f.IsBlocked() {
|
||||||
|
t.Error("IsBlocked = true after clear, want false")
|
||||||
|
}
|
||||||
|
}
|
||||||
44
internal/sdlc/history.go
Normal file
44
internal/sdlc/history.go
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
package sdlc
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// HistoryEntry records a single action taken in the SDLC lifecycle.
|
||||||
|
type HistoryEntry struct {
|
||||||
|
Timestamp time.Time `yaml:"timestamp" json:"timestamp"`
|
||||||
|
Action string `yaml:"action" json:"action"`
|
||||||
|
Feature string `yaml:"feature,omitempty" json:"feature,omitempty"`
|
||||||
|
Actor string `yaml:"actor" json:"actor"`
|
||||||
|
Result string `yaml:"result,omitempty" json:"result,omitempty"`
|
||||||
|
Output string `yaml:"output,omitempty" json:"output,omitempty"`
|
||||||
|
FromPhase string `yaml:"from_phase,omitempty" json:"from_phase,omitempty"`
|
||||||
|
ToPhase string `yaml:"to_phase,omitempty" json:"to_phase,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockedItem represents something that is blocked in the SDLC.
|
||||||
|
type BlockedItem struct {
|
||||||
|
Type string `yaml:"type" json:"type"`
|
||||||
|
Slug string `yaml:"slug" json:"slug"`
|
||||||
|
Reason string `yaml:"reason" json:"reason"`
|
||||||
|
Since string `yaml:"since,omitempty" json:"since,omitempty"`
|
||||||
|
RuleID string `yaml:"rule_id,omitempty" json:"rule_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActiveFeature tracks a feature in active_work.
|
||||||
|
type ActiveFeature struct {
|
||||||
|
Slug string `yaml:"slug" json:"slug"`
|
||||||
|
Branch string `yaml:"branch,omitempty" json:"branch,omitempty"`
|
||||||
|
Phase FeaturePhase `yaml:"phase" json:"phase"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActiveWork tracks all active items.
|
||||||
|
type ActiveWork struct {
|
||||||
|
Features []ActiveFeature `yaml:"features" json:"features"`
|
||||||
|
Patterns []string `yaml:"patterns,omitempty" json:"patterns,omitempty"`
|
||||||
|
Audits []string `yaml:"audits,omitempty" json:"audits,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectState holds project-level metadata.
|
||||||
|
type ProjectState struct {
|
||||||
|
Name string `yaml:"name" json:"name"`
|
||||||
|
CurrentRoadmap string `yaml:"current_roadmap,omitempty" json:"current_roadmap,omitempty"`
|
||||||
|
}
|
||||||
45
internal/sdlc/init.go
Normal file
45
internal/sdlc/init.go
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
package sdlc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Init creates the .sdlc/ directory structure and default files.
|
||||||
|
func Init(root, projectName string) error {
|
||||||
|
sdlcRoot := SDLCRoot(root)
|
||||||
|
|
||||||
|
// Check if already initialized
|
||||||
|
if _, err := os.Stat(sdlcRoot); err == nil {
|
||||||
|
return fmt.Errorf("already initialized: %s exists", sdlcRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create .sdlc/ and all subdirectories
|
||||||
|
for _, dir := range SubDirs() {
|
||||||
|
dirPath := filepath.Join(sdlcRoot, dir)
|
||||||
|
if err := os.MkdirAll(dirPath, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("create directory %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write default state
|
||||||
|
state := DefaultState(projectName)
|
||||||
|
if err := state.Save(root); err != nil {
|
||||||
|
return fmt.Errorf("write default state: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write default config
|
||||||
|
config := DefaultConfig(projectName)
|
||||||
|
if err := config.Save(root); err != nil {
|
||||||
|
return fmt.Errorf("write default config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsInitialized returns true if the .sdlc/ directory exists.
|
||||||
|
func IsInitialized(root string) bool {
|
||||||
|
info, err := os.Stat(SDLCRoot(root))
|
||||||
|
return err == nil && info.IsDir()
|
||||||
|
}
|
||||||
88
internal/sdlc/init_test.go
Normal file
88
internal/sdlc/init_test.go
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
package sdlc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInit(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
|
||||||
|
if err := Init(root, "test-project"); err != nil {
|
||||||
|
t.Fatalf("Init: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify .sdlc directory exists
|
||||||
|
sdlcRoot := SDLCRoot(root)
|
||||||
|
info, err := os.Stat(sdlcRoot)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stat .sdlc: %v", err)
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
t.Fatal(".sdlc is not a directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all subdirectories exist
|
||||||
|
for _, dir := range SubDirs() {
|
||||||
|
dirPath := filepath.Join(sdlcRoot, dir)
|
||||||
|
info, err := os.Stat(dirPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("stat %s: %v", dir, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
t.Errorf("%s is not a directory", dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify state.yaml
|
||||||
|
state, err := LoadState(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadState: %v", err)
|
||||||
|
}
|
||||||
|
if state.Version != 1 {
|
||||||
|
t.Errorf("state.Version = %d, want 1", state.Version)
|
||||||
|
}
|
||||||
|
if state.Project.Name != "test-project" {
|
||||||
|
t.Errorf("state.Project.Name = %q, want test-project", state.Project.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify config.yaml
|
||||||
|
config, err := LoadConfig(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig: %v", err)
|
||||||
|
}
|
||||||
|
if config.Project.Name != "test-project" {
|
||||||
|
t.Errorf("config.Project.Name = %q, want test-project", config.Project.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInitAlreadyInitialized(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
|
||||||
|
if err := Init(root, "test"); err != nil {
|
||||||
|
t.Fatalf("Init: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := Init(root, "test")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Init should fail when already initialized")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsInitialized(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
|
||||||
|
if IsInitialized(root) {
|
||||||
|
t.Error("IsInitialized = true before init")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := Init(root, "test"); err != nil {
|
||||||
|
t.Fatalf("Init: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !IsInitialized(root) {
|
||||||
|
t.Error("IsInitialized = false after init")
|
||||||
|
}
|
||||||
|
}
|
||||||
99
internal/sdlc/paths.go
Normal file
99
internal/sdlc/paths.go
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
package sdlc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// SDLCDir is the root directory name for SDLC state.
|
||||||
|
SDLCDir = ".sdlc"
|
||||||
|
|
||||||
|
// StateFile is the filename for global state.
|
||||||
|
StateFile = "state.yaml"
|
||||||
|
|
||||||
|
// ConfigFile is the filename for project config.
|
||||||
|
ConfigFile = "config.yaml"
|
||||||
|
|
||||||
|
// FeaturesDir holds per-feature subdirectories.
|
||||||
|
FeaturesDir = "features"
|
||||||
|
|
||||||
|
// PatternsDir holds pattern definitions.
|
||||||
|
PatternsDir = "patterns"
|
||||||
|
|
||||||
|
// AuditsDir holds audit reports.
|
||||||
|
AuditsDir = "audits"
|
||||||
|
|
||||||
|
// BranchesDir holds branch tracking files.
|
||||||
|
BranchesDir = "branches"
|
||||||
|
|
||||||
|
// ArchivesDir holds archived (released) features.
|
||||||
|
ArchivesDir = "archives"
|
||||||
|
|
||||||
|
// RoadmapDir holds roadmap documents.
|
||||||
|
RoadmapDir = "roadmap"
|
||||||
|
|
||||||
|
// ManifestFile is the per-feature metadata file.
|
||||||
|
ManifestFile = "manifest.yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
var slugPattern = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
|
||||||
|
|
||||||
|
// ValidateSlug returns nil if the slug is valid, ErrInvalidSlug otherwise.
|
||||||
|
func ValidateSlug(slug string) error {
|
||||||
|
if slug == "" || len(slug) > 64 || !slugPattern.MatchString(slug) {
|
||||||
|
return ErrInvalidSlug
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SDLCRoot returns the .sdlc directory path within a project root.
|
||||||
|
func SDLCRoot(root string) string {
|
||||||
|
return filepath.Join(root, SDLCDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatePath returns the path to state.yaml.
|
||||||
|
func StatePath(root string) string {
|
||||||
|
return filepath.Join(root, SDLCDir, StateFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigPath returns the path to config.yaml.
|
||||||
|
func ConfigPath(root string) string {
|
||||||
|
return filepath.Join(root, SDLCDir, ConfigFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FeaturesDirPath returns the features/ directory path.
|
||||||
|
func FeaturesDirPath(root string) string {
|
||||||
|
return filepath.Join(root, SDLCDir, FeaturesDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FeatureDir returns the directory for a specific feature.
|
||||||
|
func FeatureDir(root, slug string) string {
|
||||||
|
return filepath.Join(root, SDLCDir, FeaturesDir, slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ManifestPath returns the manifest.yaml path for a feature.
|
||||||
|
func ManifestPath(root, slug string) string {
|
||||||
|
return filepath.Join(root, SDLCDir, FeaturesDir, slug, ManifestFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArtifactPath returns the file path for a feature artifact.
|
||||||
|
func ArtifactPath(root, slug string, artifactType ArtifactType) string {
|
||||||
|
filename := ArtifactFilename(artifactType)
|
||||||
|
if filename == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return filepath.Join(root, SDLCDir, FeaturesDir, slug, filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubDirs returns all subdirectories that sdlc init creates.
|
||||||
|
func SubDirs() []string {
|
||||||
|
return []string{
|
||||||
|
FeaturesDir,
|
||||||
|
PatternsDir,
|
||||||
|
AuditsDir,
|
||||||
|
BranchesDir,
|
||||||
|
ArchivesDir,
|
||||||
|
RoadmapDir,
|
||||||
|
}
|
||||||
|
}
|
||||||
114
internal/sdlc/paths_test.go
Normal file
114
internal/sdlc/paths_test.go
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
package sdlc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStatePath(t *testing.T) {
|
||||||
|
got := StatePath("/project")
|
||||||
|
want := filepath.Join("/project", ".sdlc", "state.yaml")
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("StatePath = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigPath(t *testing.T) {
|
||||||
|
got := ConfigPath("/project")
|
||||||
|
want := filepath.Join("/project", ".sdlc", "config.yaml")
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("ConfigPath = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFeatureDir(t *testing.T) {
|
||||||
|
got := FeatureDir("/project", "auth")
|
||||||
|
want := filepath.Join("/project", ".sdlc", "features", "auth")
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("FeatureDir = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManifestPath(t *testing.T) {
|
||||||
|
got := ManifestPath("/project", "auth")
|
||||||
|
want := filepath.Join("/project", ".sdlc", "features", "auth", "manifest.yaml")
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("ManifestPath = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArtifactPath(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
artifact ArtifactType
|
||||||
|
wantFile string
|
||||||
|
}{
|
||||||
|
{ArtifactSpec, "spec.md"},
|
||||||
|
{ArtifactDesign, "design.md"},
|
||||||
|
{ArtifactTasks, "tasks.md"},
|
||||||
|
{ArtifactQAPlan, "qa-plan.md"},
|
||||||
|
{ArtifactReview, "review.md"},
|
||||||
|
{ArtifactAudit, "audit.md"},
|
||||||
|
{ArtifactQAResults, "qa-results.md"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := ArtifactPath("/project", "auth", tt.artifact)
|
||||||
|
want := filepath.Join("/project", ".sdlc", "features", "auth", tt.wantFile)
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("ArtifactPath(%q) = %q, want %q", tt.artifact, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArtifactPathInvalid(t *testing.T) {
|
||||||
|
got := ArtifactPath("/project", "auth", ArtifactType("bogus"))
|
||||||
|
if got != "" {
|
||||||
|
t.Errorf("ArtifactPath(bogus) = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateSlug(t *testing.T) {
|
||||||
|
valid := []string{"auth", "user-auth", "a1", "my-feature-2"}
|
||||||
|
for _, s := range valid {
|
||||||
|
if err := ValidateSlug(s); err != nil {
|
||||||
|
t.Errorf("ValidateSlug(%q) = %v, want nil", s, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
invalid := []string{"", "Auth", "UPPER", "123start", "has spaces", "has_underscores", "-leading"}
|
||||||
|
for _, s := range invalid {
|
||||||
|
if err := ValidateSlug(s); err == nil {
|
||||||
|
t.Errorf("ValidateSlug(%q) = nil, want error", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPhaseIndex(t *testing.T) {
|
||||||
|
if i := PhaseIndex(PhaseDraft); i != 0 {
|
||||||
|
t.Errorf("PhaseIndex(draft) = %d, want 0", i)
|
||||||
|
}
|
||||||
|
if i := PhaseIndex(PhaseReleased); i != 9 {
|
||||||
|
t.Errorf("PhaseIndex(released) = %d, want 9", i)
|
||||||
|
}
|
||||||
|
if i := PhaseIndex("bogus"); i != -1 {
|
||||||
|
t.Errorf("PhaseIndex(bogus) = %d, want -1", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsValidPhase(t *testing.T) {
|
||||||
|
if !IsValidPhase(PhaseDraft) {
|
||||||
|
t.Error("IsValidPhase(draft) = false, want true")
|
||||||
|
}
|
||||||
|
if IsValidPhase("bogus") {
|
||||||
|
t.Error("IsValidPhase(bogus) = true, want false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsValidArtifactType(t *testing.T) {
|
||||||
|
if !IsValidArtifactType(ArtifactSpec) {
|
||||||
|
t.Error("IsValidArtifactType(spec) = false, want true")
|
||||||
|
}
|
||||||
|
if IsValidArtifactType("bogus") {
|
||||||
|
t.Error("IsValidArtifactType(bogus) = true, want false")
|
||||||
|
}
|
||||||
|
}
|
||||||
478
internal/sdlc/rules.go
Normal file
478
internal/sdlc/rules.go
Normal file
@ -0,0 +1,478 @@
|
|||||||
|
package sdlc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultRules returns all classifier rules in priority order.
|
||||||
|
// First matching rule wins.
|
||||||
|
func DefaultRules() []Rule {
|
||||||
|
return []Rule{
|
||||||
|
blockedDependencyRule(),
|
||||||
|
needsSpecRule(),
|
||||||
|
specNeedsApprovalRule(),
|
||||||
|
specApprovedRule(),
|
||||||
|
needsDesignRule(),
|
||||||
|
designNeedsApprovalRule(),
|
||||||
|
needsTasksRule(),
|
||||||
|
tasksNeedApprovalRule(),
|
||||||
|
needsQAPlanRule(),
|
||||||
|
qaPlanNeedsApprovalRule(),
|
||||||
|
planningCompleteRule(),
|
||||||
|
readyToImplementRule(),
|
||||||
|
implementNextTaskRule(),
|
||||||
|
implementationCompleteRule(),
|
||||||
|
needsReviewRule(),
|
||||||
|
reviewHasIssuesRule(),
|
||||||
|
reviewPassedRule(),
|
||||||
|
needsAuditRule(),
|
||||||
|
auditHasIssuesRule(),
|
||||||
|
auditPassedRule(),
|
||||||
|
needsQARule(),
|
||||||
|
qaHasFailuresRule(),
|
||||||
|
qaPassedRule(),
|
||||||
|
needsMergeRule(),
|
||||||
|
archiveFeatureRule(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func blockedDependencyRule() Rule {
|
||||||
|
return Rule{
|
||||||
|
ID: "blocked-dependency",
|
||||||
|
Condition: func(ctx *EvalContext) bool {
|
||||||
|
return ctx.Feature.IsBlocked()
|
||||||
|
},
|
||||||
|
Action: ActionBlocked,
|
||||||
|
Message: func(ctx *EvalContext) string {
|
||||||
|
return fmt.Sprintf("Feature blocked by: %s", strings.Join(ctx.Feature.Blockers, ", "))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func needsSpecRule() Rule {
|
||||||
|
return Rule{
|
||||||
|
ID: "needs-spec",
|
||||||
|
Condition: func(ctx *EvalContext) bool {
|
||||||
|
if ctx.Feature.Phase != PhaseDraft {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
art := ctx.Feature.GetArtifact(ArtifactSpec)
|
||||||
|
return art == nil || art.Status == StatusPending
|
||||||
|
},
|
||||||
|
Action: ActionCreateSpec,
|
||||||
|
NextCommand: func(ctx *EvalContext) string {
|
||||||
|
return "/spec-feature " + ctx.Feature.Slug
|
||||||
|
},
|
||||||
|
OutputPath: func(ctx *EvalContext) string {
|
||||||
|
return ".sdlc/features/" + ctx.Feature.Slug + "/spec.md"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func specNeedsApprovalRule() Rule {
|
||||||
|
return Rule{
|
||||||
|
ID: "spec-needs-approval",
|
||||||
|
Condition: func(ctx *EvalContext) bool {
|
||||||
|
if ctx.Feature.Phase != PhaseDraft {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
art := ctx.Feature.GetArtifact(ArtifactSpec)
|
||||||
|
return art != nil && art.Status == StatusDraft
|
||||||
|
},
|
||||||
|
Action: ActionAwaitApproval,
|
||||||
|
Message: func(_ *EvalContext) string {
|
||||||
|
return "Specification requires user approval"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func specApprovedRule() Rule {
|
||||||
|
return Rule{
|
||||||
|
ID: "spec-approved",
|
||||||
|
Condition: func(ctx *EvalContext) bool {
|
||||||
|
if ctx.Feature.Phase != PhaseDraft {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
art := ctx.Feature.GetArtifact(ArtifactSpec)
|
||||||
|
return art != nil && art.Status == StatusApproved
|
||||||
|
},
|
||||||
|
Action: ActionTransition,
|
||||||
|
TransitionTo: PhaseSpecified,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func needsDesignRule() Rule {
|
||||||
|
return Rule{
|
||||||
|
ID: "needs-design",
|
||||||
|
Condition: func(ctx *EvalContext) bool {
|
||||||
|
if ctx.Feature.Phase != PhaseSpecified {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
art := ctx.Feature.GetArtifact(ArtifactDesign)
|
||||||
|
return art == nil || art.Status == StatusPending
|
||||||
|
},
|
||||||
|
Action: ActionCreateDesign,
|
||||||
|
NextCommand: func(ctx *EvalContext) string {
|
||||||
|
return "/design-feature " + ctx.Feature.Slug
|
||||||
|
},
|
||||||
|
OutputPath: func(ctx *EvalContext) string {
|
||||||
|
return ".sdlc/features/" + ctx.Feature.Slug + "/design.md"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func designNeedsApprovalRule() Rule {
|
||||||
|
return Rule{
|
||||||
|
ID: "design-needs-approval",
|
||||||
|
Condition: func(ctx *EvalContext) bool {
|
||||||
|
if ctx.Feature.Phase != PhaseSpecified {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
art := ctx.Feature.GetArtifact(ArtifactDesign)
|
||||||
|
return art != nil && (art.Status == StatusDraft || art.Status == StatusRejected)
|
||||||
|
},
|
||||||
|
Action: ActionAwaitApproval,
|
||||||
|
Message: func(_ *EvalContext) string {
|
||||||
|
return "Design document requires user approval"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func needsTasksRule() Rule {
|
||||||
|
return Rule{
|
||||||
|
ID: "needs-tasks",
|
||||||
|
Condition: func(ctx *EvalContext) bool {
|
||||||
|
if ctx.Feature.Phase != PhaseSpecified {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
design := ctx.Feature.GetArtifact(ArtifactDesign)
|
||||||
|
if design == nil || design.Status != StatusApproved {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
tasks := ctx.Feature.GetArtifact(ArtifactTasks)
|
||||||
|
return tasks == nil || tasks.Status == StatusPending
|
||||||
|
},
|
||||||
|
Action: ActionCreateTasks,
|
||||||
|
NextCommand: func(ctx *EvalContext) string {
|
||||||
|
return "/breakdown-feature " + ctx.Feature.Slug
|
||||||
|
},
|
||||||
|
OutputPath: func(ctx *EvalContext) string {
|
||||||
|
return ".sdlc/features/" + ctx.Feature.Slug + "/tasks.md"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tasksNeedApprovalRule() Rule {
|
||||||
|
return Rule{
|
||||||
|
ID: "tasks-need-approval",
|
||||||
|
Condition: func(ctx *EvalContext) bool {
|
||||||
|
if ctx.Feature.Phase != PhaseSpecified {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
tasks := ctx.Feature.GetArtifact(ArtifactTasks)
|
||||||
|
return tasks != nil && (tasks.Status == StatusDraft || tasks.Status == StatusRejected)
|
||||||
|
},
|
||||||
|
Action: ActionAwaitApproval,
|
||||||
|
Message: func(_ *EvalContext) string {
|
||||||
|
return "Task breakdown requires user approval"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func needsQAPlanRule() Rule {
|
||||||
|
return Rule{
|
||||||
|
ID: "needs-qa-plan",
|
||||||
|
Condition: func(ctx *EvalContext) bool {
|
||||||
|
if ctx.Feature.Phase != PhaseSpecified {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
tasks := ctx.Feature.GetArtifact(ArtifactTasks)
|
||||||
|
if tasks == nil || tasks.Status != StatusApproved {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
qa := ctx.Feature.GetArtifact(ArtifactQAPlan)
|
||||||
|
return qa == nil || qa.Status == StatusPending
|
||||||
|
},
|
||||||
|
Action: ActionCreateQAPlan,
|
||||||
|
NextCommand: func(ctx *EvalContext) string {
|
||||||
|
return "/create-qa-plan " + ctx.Feature.Slug
|
||||||
|
},
|
||||||
|
OutputPath: func(ctx *EvalContext) string {
|
||||||
|
return ".sdlc/features/" + ctx.Feature.Slug + "/qa-plan.md"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func qaPlanNeedsApprovalRule() Rule {
|
||||||
|
return Rule{
|
||||||
|
ID: "qa-plan-needs-approval",
|
||||||
|
Condition: func(ctx *EvalContext) bool {
|
||||||
|
if ctx.Feature.Phase != PhaseSpecified {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
qa := ctx.Feature.GetArtifact(ArtifactQAPlan)
|
||||||
|
return qa != nil && (qa.Status == StatusDraft || qa.Status == StatusRejected)
|
||||||
|
},
|
||||||
|
Action: ActionAwaitApproval,
|
||||||
|
Message: func(_ *EvalContext) string {
|
||||||
|
return "QA plan requires user approval"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func planningCompleteRule() Rule {
|
||||||
|
return Rule{
|
||||||
|
ID: "planning-complete",
|
||||||
|
Condition: func(ctx *EvalContext) bool {
|
||||||
|
if ctx.Feature.Phase != PhaseSpecified {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
qa := ctx.Feature.GetArtifact(ArtifactQAPlan)
|
||||||
|
return qa != nil && qa.Status == StatusApproved
|
||||||
|
},
|
||||||
|
Action: ActionTransition,
|
||||||
|
TransitionTo: PhasePlanned,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readyToImplementRule() Rule {
|
||||||
|
return Rule{
|
||||||
|
ID: "ready-to-implement",
|
||||||
|
Condition: func(ctx *EvalContext) bool {
|
||||||
|
return ctx.Feature.Phase == PhasePlanned || ctx.Feature.Phase == PhaseReady
|
||||||
|
},
|
||||||
|
Action: ActionTransition,
|
||||||
|
TransitionTo: PhaseReady,
|
||||||
|
Message: func(ctx *EvalContext) string {
|
||||||
|
if ctx.Feature.Phase == PhasePlanned {
|
||||||
|
return "Ready for implementation: transition to ready, then implementation"
|
||||||
|
}
|
||||||
|
return "Ready for implementation"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func implementNextTaskRule() Rule {
|
||||||
|
return Rule{
|
||||||
|
ID: "implement-next-task",
|
||||||
|
Condition: func(ctx *EvalContext) bool {
|
||||||
|
if ctx.Feature.Phase != PhaseImplementation {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
next := NextTask(ctx.Feature.Tasks)
|
||||||
|
return next != nil
|
||||||
|
},
|
||||||
|
Action: ActionImplementTask,
|
||||||
|
NextCommand: func(ctx *EvalContext) string {
|
||||||
|
next := NextTask(ctx.Feature.Tasks)
|
||||||
|
if next == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("/implement-task %s %s", ctx.Feature.Slug, next.ID)
|
||||||
|
},
|
||||||
|
OutputPath: func(ctx *EvalContext) string {
|
||||||
|
return ".sdlc/features/" + ctx.Feature.Slug + "/tasks.md"
|
||||||
|
},
|
||||||
|
TaskID: func(ctx *EvalContext) string {
|
||||||
|
next := NextTask(ctx.Feature.Tasks)
|
||||||
|
if next == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return next.ID
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func implementationCompleteRule() Rule {
|
||||||
|
return Rule{
|
||||||
|
ID: "implementation-complete",
|
||||||
|
Condition: func(ctx *EvalContext) bool {
|
||||||
|
return ctx.Feature.Phase == PhaseImplementation && AllTasksComplete(ctx.Feature.Tasks)
|
||||||
|
},
|
||||||
|
Action: ActionTransition,
|
||||||
|
TransitionTo: PhaseReview,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func needsReviewRule() Rule {
|
||||||
|
return Rule{
|
||||||
|
ID: "needs-review",
|
||||||
|
Condition: func(ctx *EvalContext) bool {
|
||||||
|
if ctx.Feature.Phase != PhaseReview {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
art := ctx.Feature.GetArtifact(ArtifactReview)
|
||||||
|
return art == nil || art.Status == StatusPending
|
||||||
|
},
|
||||||
|
Action: ActionReviewCode,
|
||||||
|
NextCommand: func(ctx *EvalContext) string {
|
||||||
|
return "/review-feature " + ctx.Feature.Slug
|
||||||
|
},
|
||||||
|
OutputPath: func(ctx *EvalContext) string {
|
||||||
|
return ".sdlc/features/" + ctx.Feature.Slug + "/review.md"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reviewHasIssuesRule() Rule {
|
||||||
|
return Rule{
|
||||||
|
ID: "review-has-issues",
|
||||||
|
Condition: func(ctx *EvalContext) bool {
|
||||||
|
if ctx.Feature.Phase != PhaseReview {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
art := ctx.Feature.GetArtifact(ArtifactReview)
|
||||||
|
return art != nil && art.Status == StatusNeedsFix
|
||||||
|
},
|
||||||
|
Action: ActionFixReviewIssues,
|
||||||
|
NextCommand: func(ctx *EvalContext) string {
|
||||||
|
return "/fix-review-issues " + ctx.Feature.Slug
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reviewPassedRule() Rule {
|
||||||
|
return Rule{
|
||||||
|
ID: "review-passed",
|
||||||
|
Condition: func(ctx *EvalContext) bool {
|
||||||
|
if ctx.Feature.Phase != PhaseReview {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
art := ctx.Feature.GetArtifact(ArtifactReview)
|
||||||
|
return art != nil && art.Status == StatusPassed
|
||||||
|
},
|
||||||
|
Action: ActionTransition,
|
||||||
|
TransitionTo: PhaseAudit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func needsAuditRule() Rule {
|
||||||
|
return Rule{
|
||||||
|
ID: "needs-audit",
|
||||||
|
Condition: func(ctx *EvalContext) bool {
|
||||||
|
if ctx.Feature.Phase != PhaseAudit {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
art := ctx.Feature.GetArtifact(ArtifactAudit)
|
||||||
|
return art == nil || art.Status == StatusPending
|
||||||
|
},
|
||||||
|
Action: ActionAuditCode,
|
||||||
|
NextCommand: func(ctx *EvalContext) string {
|
||||||
|
return "/audit-feature " + ctx.Feature.Slug
|
||||||
|
},
|
||||||
|
OutputPath: func(ctx *EvalContext) string {
|
||||||
|
return ".sdlc/features/" + ctx.Feature.Slug + "/audit.md"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func auditHasIssuesRule() Rule {
|
||||||
|
return Rule{
|
||||||
|
ID: "audit-has-issues",
|
||||||
|
Condition: func(ctx *EvalContext) bool {
|
||||||
|
if ctx.Feature.Phase != PhaseAudit {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
art := ctx.Feature.GetArtifact(ArtifactAudit)
|
||||||
|
return art != nil && art.Status == StatusNeedsFix
|
||||||
|
},
|
||||||
|
Action: ActionRemediateAudit,
|
||||||
|
NextCommand: func(ctx *EvalContext) string {
|
||||||
|
return "/remediate-audit " + ctx.Feature.Slug
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func auditPassedRule() Rule {
|
||||||
|
return Rule{
|
||||||
|
ID: "audit-passed",
|
||||||
|
Condition: func(ctx *EvalContext) bool {
|
||||||
|
if ctx.Feature.Phase != PhaseAudit {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
art := ctx.Feature.GetArtifact(ArtifactAudit)
|
||||||
|
return art != nil && art.Status == StatusPassed
|
||||||
|
},
|
||||||
|
Action: ActionTransition,
|
||||||
|
TransitionTo: PhaseQA,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func needsQARule() Rule {
|
||||||
|
return Rule{
|
||||||
|
ID: "needs-qa",
|
||||||
|
Condition: func(ctx *EvalContext) bool {
|
||||||
|
if ctx.Feature.Phase != PhaseQA {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
art := ctx.Feature.GetArtifact(ArtifactQAResults)
|
||||||
|
return art == nil || art.Status == StatusPending
|
||||||
|
},
|
||||||
|
Action: ActionRunQA,
|
||||||
|
NextCommand: func(ctx *EvalContext) string {
|
||||||
|
return "/run-qa " + ctx.Feature.Slug
|
||||||
|
},
|
||||||
|
OutputPath: func(ctx *EvalContext) string {
|
||||||
|
return ".sdlc/features/" + ctx.Feature.Slug + "/qa-results.md"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func qaHasFailuresRule() Rule {
|
||||||
|
return Rule{
|
||||||
|
ID: "qa-has-failures",
|
||||||
|
Condition: func(ctx *EvalContext) bool {
|
||||||
|
if ctx.Feature.Phase != PhaseQA {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
art := ctx.Feature.GetArtifact(ArtifactQAResults)
|
||||||
|
return art != nil && art.Status == StatusFailed
|
||||||
|
},
|
||||||
|
Action: ActionFixQAFailures,
|
||||||
|
NextCommand: func(ctx *EvalContext) string {
|
||||||
|
return "/fix-qa-failures " + ctx.Feature.Slug
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func qaPassedRule() Rule {
|
||||||
|
return Rule{
|
||||||
|
ID: "qa-passed",
|
||||||
|
Condition: func(ctx *EvalContext) bool {
|
||||||
|
if ctx.Feature.Phase != PhaseQA {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
art := ctx.Feature.GetArtifact(ArtifactQAResults)
|
||||||
|
return art != nil && art.Status == StatusPassed
|
||||||
|
},
|
||||||
|
Action: ActionTransition,
|
||||||
|
TransitionTo: PhaseMerge,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func needsMergeRule() Rule {
|
||||||
|
return Rule{
|
||||||
|
ID: "needs-merge",
|
||||||
|
Condition: func(ctx *EvalContext) bool {
|
||||||
|
return ctx.Feature.Phase == PhaseMerge
|
||||||
|
},
|
||||||
|
Action: ActionMergeFeature,
|
||||||
|
NextCommand: func(ctx *EvalContext) string {
|
||||||
|
return "/merge-feature " + ctx.Feature.Slug
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func archiveFeatureRule() Rule {
|
||||||
|
return Rule{
|
||||||
|
ID: "archive-feature",
|
||||||
|
Condition: func(ctx *EvalContext) bool {
|
||||||
|
return ctx.Feature.Phase == PhaseReleased
|
||||||
|
},
|
||||||
|
Action: ActionArchive,
|
||||||
|
NextCommand: func(ctx *EvalContext) string {
|
||||||
|
return "/archive-feature " + ctx.Feature.Slug
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
121
internal/sdlc/state.go
Normal file
121
internal/sdlc/state.go
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
package sdlc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// State represents the global SDLC state stored in .sdlc/state.yaml.
|
||||||
|
type State struct {
|
||||||
|
Version int `yaml:"version" json:"version"`
|
||||||
|
Project ProjectState `yaml:"project" json:"project"`
|
||||||
|
ActiveWork ActiveWork `yaml:"active_work" json:"active_work"`
|
||||||
|
Blocked []BlockedItem `yaml:"blocked" json:"blocked"`
|
||||||
|
LastUpdated *time.Time `yaml:"last_updated,omitempty" json:"last_updated,omitempty"`
|
||||||
|
LastAction string `yaml:"last_action,omitempty" json:"last_action,omitempty"`
|
||||||
|
LastActor string `yaml:"last_actor,omitempty" json:"last_actor,omitempty"`
|
||||||
|
History []HistoryEntry `yaml:"history,omitempty" json:"history,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadState reads and parses .sdlc/state.yaml from the given project root.
|
||||||
|
func LoadState(root string) (*State, error) {
|
||||||
|
path := StatePath(root)
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, ErrNotInitialized
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("read state file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var s State
|
||||||
|
if err := yaml.Unmarshal(data, &s); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse state file: %w", err)
|
||||||
|
}
|
||||||
|
return &s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save writes the state to .sdlc/state.yaml.
|
||||||
|
func (s *State) Save(root string) error {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
s.LastUpdated = &now
|
||||||
|
|
||||||
|
data, err := yaml.Marshal(s)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal state: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
path := StatePath(root)
|
||||||
|
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||||
|
return fmt.Errorf("write state file: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordAction appends a history entry and updates the last-action fields.
|
||||||
|
func (s *State) RecordAction(action, feature, actor string) {
|
||||||
|
entry := HistoryEntry{
|
||||||
|
Timestamp: time.Now().UTC(),
|
||||||
|
Action: action,
|
||||||
|
Feature: feature,
|
||||||
|
Actor: actor,
|
||||||
|
Result: "success",
|
||||||
|
}
|
||||||
|
s.History = append(s.History, entry)
|
||||||
|
s.LastAction = action
|
||||||
|
s.LastActor = actor
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddActiveFeature adds a feature to active work if not already present.
|
||||||
|
func (s *State) AddActiveFeature(slug string, phase FeaturePhase) {
|
||||||
|
for _, f := range s.ActiveWork.Features {
|
||||||
|
if f.Slug == slug {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.ActiveWork.Features = append(s.ActiveWork.Features, ActiveFeature{
|
||||||
|
Slug: slug,
|
||||||
|
Phase: phase,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateActiveFeature updates the phase of an active feature.
|
||||||
|
func (s *State) UpdateActiveFeature(slug string, phase FeaturePhase, branch string) {
|
||||||
|
for i, f := range s.ActiveWork.Features {
|
||||||
|
if f.Slug == slug {
|
||||||
|
s.ActiveWork.Features[i].Phase = phase
|
||||||
|
if branch != "" {
|
||||||
|
s.ActiveWork.Features[i].Branch = branch
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveActiveFeature removes a feature from active work.
|
||||||
|
func (s *State) RemoveActiveFeature(slug string) {
|
||||||
|
for i, f := range s.ActiveWork.Features {
|
||||||
|
if f.Slug == slug {
|
||||||
|
s.ActiveWork.Features = append(s.ActiveWork.Features[:i], s.ActiveWork.Features[i+1:]...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultState returns a new State with version 1 and empty fields.
|
||||||
|
func DefaultState(projectName string) *State {
|
||||||
|
return &State{
|
||||||
|
Version: 1,
|
||||||
|
Project: ProjectState{
|
||||||
|
Name: projectName,
|
||||||
|
},
|
||||||
|
ActiveWork: ActiveWork{
|
||||||
|
Features: []ActiveFeature{},
|
||||||
|
},
|
||||||
|
Blocked: []BlockedItem{},
|
||||||
|
History: []HistoryEntry{},
|
||||||
|
}
|
||||||
|
}
|
||||||
111
internal/sdlc/state_test.go
Normal file
111
internal/sdlc/state_test.go
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
package sdlc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStateRoundTrip(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
|
||||||
|
// Create .sdlc directory
|
||||||
|
if err := os.MkdirAll(SDLCRoot(root), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
original := DefaultState("test-project")
|
||||||
|
original.RecordAction("test-action", "auth", "tester")
|
||||||
|
original.AddActiveFeature("auth", PhaseDraft)
|
||||||
|
|
||||||
|
if err := original.Save(root); err != nil {
|
||||||
|
t.Fatalf("Save: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded, err := LoadState(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadState: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if loaded.Version != 1 {
|
||||||
|
t.Errorf("Version = %d, want 1", loaded.Version)
|
||||||
|
}
|
||||||
|
if loaded.Project.Name != "test-project" {
|
||||||
|
t.Errorf("Project.Name = %q, want %q", loaded.Project.Name, "test-project")
|
||||||
|
}
|
||||||
|
if len(loaded.History) != 1 {
|
||||||
|
t.Fatalf("History len = %d, want 1", len(loaded.History))
|
||||||
|
}
|
||||||
|
if loaded.History[0].Action != "test-action" {
|
||||||
|
t.Errorf("History[0].Action = %q, want %q", loaded.History[0].Action, "test-action")
|
||||||
|
}
|
||||||
|
if loaded.LastAction != "test-action" {
|
||||||
|
t.Errorf("LastAction = %q, want %q", loaded.LastAction, "test-action")
|
||||||
|
}
|
||||||
|
if loaded.LastActor != "tester" {
|
||||||
|
t.Errorf("LastActor = %q, want %q", loaded.LastActor, "tester")
|
||||||
|
}
|
||||||
|
if len(loaded.ActiveWork.Features) != 1 {
|
||||||
|
t.Fatalf("ActiveWork.Features len = %d, want 1", len(loaded.ActiveWork.Features))
|
||||||
|
}
|
||||||
|
if loaded.ActiveWork.Features[0].Slug != "auth" {
|
||||||
|
t.Errorf("ActiveWork.Features[0].Slug = %q, want %q", loaded.ActiveWork.Features[0].Slug, "auth")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadStateNotInitialized(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
_, err := LoadState(root)
|
||||||
|
if err != ErrNotInitialized {
|
||||||
|
t.Errorf("LoadState = %v, want ErrNotInitialized", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecordAction(t *testing.T) {
|
||||||
|
s := DefaultState("test")
|
||||||
|
s.RecordAction("CREATE_SPEC", "auth", "claude")
|
||||||
|
s.RecordAction("TRANSITION", "auth", "classifier")
|
||||||
|
|
||||||
|
if len(s.History) != 2 {
|
||||||
|
t.Fatalf("History len = %d, want 2", len(s.History))
|
||||||
|
}
|
||||||
|
if s.LastAction != "TRANSITION" {
|
||||||
|
t.Errorf("LastAction = %q, want TRANSITION", s.LastAction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddActiveFeatureDeduplicate(t *testing.T) {
|
||||||
|
s := DefaultState("test")
|
||||||
|
s.AddActiveFeature("auth", PhaseDraft)
|
||||||
|
s.AddActiveFeature("auth", PhaseDraft) // duplicate
|
||||||
|
|
||||||
|
if len(s.ActiveWork.Features) != 1 {
|
||||||
|
t.Errorf("Features len = %d, want 1", len(s.ActiveWork.Features))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateActiveFeature(t *testing.T) {
|
||||||
|
s := DefaultState("test")
|
||||||
|
s.AddActiveFeature("auth", PhaseDraft)
|
||||||
|
s.UpdateActiveFeature("auth", PhaseSpecified, "feature/auth")
|
||||||
|
|
||||||
|
if s.ActiveWork.Features[0].Phase != PhaseSpecified {
|
||||||
|
t.Errorf("Phase = %q, want specified", s.ActiveWork.Features[0].Phase)
|
||||||
|
}
|
||||||
|
if s.ActiveWork.Features[0].Branch != "feature/auth" {
|
||||||
|
t.Errorf("Branch = %q, want feature/auth", s.ActiveWork.Features[0].Branch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveActiveFeature(t *testing.T) {
|
||||||
|
s := DefaultState("test")
|
||||||
|
s.AddActiveFeature("auth", PhaseDraft)
|
||||||
|
s.AddActiveFeature("payments", PhaseDraft)
|
||||||
|
s.RemoveActiveFeature("auth")
|
||||||
|
|
||||||
|
if len(s.ActiveWork.Features) != 1 {
|
||||||
|
t.Fatalf("Features len = %d, want 1", len(s.ActiveWork.Features))
|
||||||
|
}
|
||||||
|
if s.ActiveWork.Features[0].Slug != "payments" {
|
||||||
|
t.Errorf("Features[0].Slug = %q, want payments", s.ActiveWork.Features[0].Slug)
|
||||||
|
}
|
||||||
|
}
|
||||||
134
internal/sdlc/task.go
Normal file
134
internal/sdlc/task.go
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
package sdlc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Task represents an implementation task within a feature.
|
||||||
|
type Task struct {
|
||||||
|
ID string `yaml:"id" json:"id"`
|
||||||
|
Title string `yaml:"title" json:"title"`
|
||||||
|
Status TaskStatus `yaml:"status" json:"status"`
|
||||||
|
Spec string `yaml:"spec,omitempty" json:"spec,omitempty"`
|
||||||
|
Files []string `yaml:"files,omitempty" json:"files,omitempty"`
|
||||||
|
Patterns []string `yaml:"patterns,omitempty" json:"patterns,omitempty"`
|
||||||
|
DependsOn []string `yaml:"depends_on,omitempty" json:"depends_on,omitempty"`
|
||||||
|
StartedAt *time.Time `yaml:"started_at,omitempty" json:"started_at,omitempty"`
|
||||||
|
DoneAt *time.Time `yaml:"done_at,omitempty" json:"done_at,omitempty"`
|
||||||
|
Notes string `yaml:"notes,omitempty" json:"notes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartTask marks a task as in-progress.
|
||||||
|
func StartTask(tasks []Task, taskID string) ([]Task, error) {
|
||||||
|
for i, t := range tasks {
|
||||||
|
if t.ID == taskID {
|
||||||
|
if t.Status != TaskPending && t.Status != TaskBlocked {
|
||||||
|
return tasks, fmt.Errorf("task %s is %s, not startable", taskID, t.Status)
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
tasks[i].Status = TaskInProgress
|
||||||
|
tasks[i].StartedAt = &now
|
||||||
|
return tasks, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tasks, ErrTaskNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompleteTask marks a task as complete.
|
||||||
|
func CompleteTask(tasks []Task, taskID string) ([]Task, error) {
|
||||||
|
for i, t := range tasks {
|
||||||
|
if t.ID == taskID {
|
||||||
|
if t.Status != TaskInProgress {
|
||||||
|
return tasks, fmt.Errorf("task %s is %s, not completable", taskID, t.Status)
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
tasks[i].Status = TaskComplete
|
||||||
|
tasks[i].DoneAt = &now
|
||||||
|
return tasks, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tasks, ErrTaskNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockTask marks a task as blocked.
|
||||||
|
func BlockTask(tasks []Task, taskID string) ([]Task, error) {
|
||||||
|
for i, t := range tasks {
|
||||||
|
if t.ID == taskID {
|
||||||
|
tasks[i].Status = TaskBlocked
|
||||||
|
return tasks, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tasks, ErrTaskNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddTask appends a new task with an auto-generated ID.
|
||||||
|
func AddTask(tasks []Task, title string) []Task {
|
||||||
|
id := fmt.Sprintf("task-%03d", len(tasks)+1)
|
||||||
|
return append(tasks, Task{
|
||||||
|
ID: id,
|
||||||
|
Title: title,
|
||||||
|
Status: TaskPending,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PendingTasks returns tasks that are pending or blocked.
|
||||||
|
func PendingTasks(tasks []Task) []Task {
|
||||||
|
var result []Task
|
||||||
|
for _, t := range tasks {
|
||||||
|
if t.Status == TaskPending {
|
||||||
|
result = append(result, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// NextTask returns the first pending task, or nil if none.
|
||||||
|
func NextTask(tasks []Task) *Task {
|
||||||
|
for i, t := range tasks {
|
||||||
|
if t.Status == TaskPending {
|
||||||
|
return &tasks[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllTasksComplete returns true if every task is in the complete state.
|
||||||
|
func AllTasksComplete(tasks []Task) bool {
|
||||||
|
if len(tasks) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, t := range tasks {
|
||||||
|
if t.Status != TaskComplete {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskSummary returns counts by status.
|
||||||
|
type TaskSummary struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
Completed int `json:"completed"`
|
||||||
|
InProgress int `json:"in_progress"`
|
||||||
|
Pending int `json:"pending"`
|
||||||
|
Blocked int `json:"blocked"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SummarizeTasks computes a TaskSummary from a task list.
|
||||||
|
func SummarizeTasks(tasks []Task) TaskSummary {
|
||||||
|
s := TaskSummary{Total: len(tasks)}
|
||||||
|
for _, t := range tasks {
|
||||||
|
switch t.Status {
|
||||||
|
case TaskComplete:
|
||||||
|
s.Completed++
|
||||||
|
case TaskInProgress:
|
||||||
|
s.InProgress++
|
||||||
|
case TaskPending:
|
||||||
|
s.Pending++
|
||||||
|
case TaskBlocked:
|
||||||
|
s.Blocked++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
163
internal/sdlc/task_test.go
Normal file
163
internal/sdlc/task_test.go
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
package sdlc
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestAddTask(t *testing.T) {
|
||||||
|
tasks := AddTask(nil, "Create user model")
|
||||||
|
tasks = AddTask(tasks, "Add validation")
|
||||||
|
|
||||||
|
if len(tasks) != 2 {
|
||||||
|
t.Fatalf("len = %d, want 2", len(tasks))
|
||||||
|
}
|
||||||
|
if tasks[0].ID != "task-001" {
|
||||||
|
t.Errorf("tasks[0].ID = %q, want task-001", tasks[0].ID)
|
||||||
|
}
|
||||||
|
if tasks[1].ID != "task-002" {
|
||||||
|
t.Errorf("tasks[1].ID = %q, want task-002", tasks[1].ID)
|
||||||
|
}
|
||||||
|
if tasks[0].Status != TaskPending {
|
||||||
|
t.Errorf("tasks[0].Status = %q, want pending", tasks[0].Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartTask(t *testing.T) {
|
||||||
|
tasks := AddTask(nil, "Task 1")
|
||||||
|
|
||||||
|
tasks, err := StartTask(tasks, "task-001")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StartTask: %v", err)
|
||||||
|
}
|
||||||
|
if tasks[0].Status != TaskInProgress {
|
||||||
|
t.Errorf("Status = %q, want in_progress", tasks[0].Status)
|
||||||
|
}
|
||||||
|
if tasks[0].StartedAt == nil {
|
||||||
|
t.Error("StartedAt is nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartTaskNotFound(t *testing.T) {
|
||||||
|
tasks := AddTask(nil, "Task 1")
|
||||||
|
_, err := StartTask(tasks, "task-999")
|
||||||
|
if err != ErrTaskNotFound {
|
||||||
|
t.Errorf("err = %v, want ErrTaskNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartTaskWrongStatus(t *testing.T) {
|
||||||
|
tasks := AddTask(nil, "Task 1")
|
||||||
|
tasks, _ = StartTask(tasks, "task-001")
|
||||||
|
tasks, _ = CompleteTask(tasks, "task-001")
|
||||||
|
|
||||||
|
_, err := StartTask(tasks, "task-001")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("StartTask on complete task should fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompleteTask(t *testing.T) {
|
||||||
|
tasks := AddTask(nil, "Task 1")
|
||||||
|
tasks, _ = StartTask(tasks, "task-001")
|
||||||
|
|
||||||
|
tasks, err := CompleteTask(tasks, "task-001")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CompleteTask: %v", err)
|
||||||
|
}
|
||||||
|
if tasks[0].Status != TaskComplete {
|
||||||
|
t.Errorf("Status = %q, want complete", tasks[0].Status)
|
||||||
|
}
|
||||||
|
if tasks[0].DoneAt == nil {
|
||||||
|
t.Error("DoneAt is nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompleteTaskWrongStatus(t *testing.T) {
|
||||||
|
tasks := AddTask(nil, "Task 1")
|
||||||
|
_, err := CompleteTask(tasks, "task-001")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("CompleteTask on pending task should fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlockTask(t *testing.T) {
|
||||||
|
tasks := AddTask(nil, "Task 1")
|
||||||
|
tasks, err := BlockTask(tasks, "task-001")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BlockTask: %v", err)
|
||||||
|
}
|
||||||
|
if tasks[0].Status != TaskBlocked {
|
||||||
|
t.Errorf("Status = %q, want blocked", tasks[0].Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPendingTasks(t *testing.T) {
|
||||||
|
tasks := AddTask(nil, "Task 1")
|
||||||
|
tasks = AddTask(tasks, "Task 2")
|
||||||
|
tasks = AddTask(tasks, "Task 3")
|
||||||
|
tasks, _ = StartTask(tasks, "task-001")
|
||||||
|
tasks, _ = CompleteTask(tasks, "task-001")
|
||||||
|
|
||||||
|
pending := PendingTasks(tasks)
|
||||||
|
if len(pending) != 2 {
|
||||||
|
t.Errorf("PendingTasks len = %d, want 2", len(pending))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNextTask(t *testing.T) {
|
||||||
|
tasks := AddTask(nil, "Task 1")
|
||||||
|
tasks = AddTask(tasks, "Task 2")
|
||||||
|
tasks, _ = StartTask(tasks, "task-001")
|
||||||
|
tasks, _ = CompleteTask(tasks, "task-001")
|
||||||
|
|
||||||
|
next := NextTask(tasks)
|
||||||
|
if next == nil {
|
||||||
|
t.Fatal("NextTask = nil, want task-002")
|
||||||
|
}
|
||||||
|
if next.ID != "task-002" {
|
||||||
|
t.Errorf("NextTask.ID = %q, want task-002", next.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllTasksComplete(t *testing.T) {
|
||||||
|
if AllTasksComplete(nil) {
|
||||||
|
t.Error("AllTasksComplete(nil) = true, want false")
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks := AddTask(nil, "Task 1")
|
||||||
|
tasks = AddTask(tasks, "Task 2")
|
||||||
|
|
||||||
|
if AllTasksComplete(tasks) {
|
||||||
|
t.Error("AllTasksComplete = true with pending tasks")
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks, _ = StartTask(tasks, "task-001")
|
||||||
|
tasks, _ = CompleteTask(tasks, "task-001")
|
||||||
|
tasks, _ = StartTask(tasks, "task-002")
|
||||||
|
tasks, _ = CompleteTask(tasks, "task-002")
|
||||||
|
|
||||||
|
if !AllTasksComplete(tasks) {
|
||||||
|
t.Error("AllTasksComplete = false with all complete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSummarizeTasks(t *testing.T) {
|
||||||
|
tasks := AddTask(nil, "Task 1")
|
||||||
|
tasks = AddTask(tasks, "Task 2")
|
||||||
|
tasks = AddTask(tasks, "Task 3")
|
||||||
|
tasks, _ = StartTask(tasks, "task-001")
|
||||||
|
tasks, _ = CompleteTask(tasks, "task-001")
|
||||||
|
tasks, _ = StartTask(tasks, "task-002")
|
||||||
|
|
||||||
|
s := SummarizeTasks(tasks)
|
||||||
|
if s.Total != 3 {
|
||||||
|
t.Errorf("Total = %d, want 3", s.Total)
|
||||||
|
}
|
||||||
|
if s.Completed != 1 {
|
||||||
|
t.Errorf("Completed = %d, want 1", s.Completed)
|
||||||
|
}
|
||||||
|
if s.InProgress != 1 {
|
||||||
|
t.Errorf("InProgress = %d, want 1", s.InProgress)
|
||||||
|
}
|
||||||
|
if s.Pending != 1 {
|
||||||
|
t.Errorf("Pending = %d, want 1", s.Pending)
|
||||||
|
}
|
||||||
|
}
|
||||||
162
internal/sdlc/types.go
Normal file
162
internal/sdlc/types.go
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
package sdlc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FeaturePhase represents a stage in the feature lifecycle.
|
||||||
|
type FeaturePhase string
|
||||||
|
|
||||||
|
const (
|
||||||
|
PhaseDraft FeaturePhase = "draft"
|
||||||
|
PhaseSpecified FeaturePhase = "specified"
|
||||||
|
PhasePlanned FeaturePhase = "planned"
|
||||||
|
PhaseReady FeaturePhase = "ready"
|
||||||
|
PhaseImplementation FeaturePhase = "implementation"
|
||||||
|
PhaseReview FeaturePhase = "review"
|
||||||
|
PhaseAudit FeaturePhase = "audit"
|
||||||
|
PhaseQA FeaturePhase = "qa"
|
||||||
|
PhaseMerge FeaturePhase = "merge"
|
||||||
|
PhaseReleased FeaturePhase = "released"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidPhases is the ordered list of all feature phases.
|
||||||
|
var ValidPhases = []FeaturePhase{
|
||||||
|
PhaseDraft,
|
||||||
|
PhaseSpecified,
|
||||||
|
PhasePlanned,
|
||||||
|
PhaseReady,
|
||||||
|
PhaseImplementation,
|
||||||
|
PhaseReview,
|
||||||
|
PhaseAudit,
|
||||||
|
PhaseQA,
|
||||||
|
PhaseMerge,
|
||||||
|
PhaseReleased,
|
||||||
|
}
|
||||||
|
|
||||||
|
// PhaseIndex returns the ordinal position of a phase, or -1 if invalid.
|
||||||
|
func PhaseIndex(p FeaturePhase) int {
|
||||||
|
for i, v := range ValidPhases {
|
||||||
|
if v == p {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValidPhase returns true if the phase is recognized.
|
||||||
|
func IsValidPhase(p FeaturePhase) bool {
|
||||||
|
return PhaseIndex(p) >= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArtifactType identifies the kind of artifact produced during a phase.
|
||||||
|
type ArtifactType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ArtifactSpec ArtifactType = "spec"
|
||||||
|
ArtifactDesign ArtifactType = "design"
|
||||||
|
ArtifactTasks ArtifactType = "tasks"
|
||||||
|
ArtifactQAPlan ArtifactType = "qa_plan"
|
||||||
|
ArtifactReview ArtifactType = "review"
|
||||||
|
ArtifactAudit ArtifactType = "audit"
|
||||||
|
ArtifactQAResults ArtifactType = "qa_results"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidArtifactTypes lists all recognized artifact types.
|
||||||
|
var ValidArtifactTypes = []ArtifactType{
|
||||||
|
ArtifactSpec,
|
||||||
|
ArtifactDesign,
|
||||||
|
ArtifactTasks,
|
||||||
|
ArtifactQAPlan,
|
||||||
|
ArtifactReview,
|
||||||
|
ArtifactAudit,
|
||||||
|
ArtifactQAResults,
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValidArtifactType returns true if the artifact type is recognized.
|
||||||
|
func IsValidArtifactType(t ArtifactType) bool {
|
||||||
|
return slices.Contains(ValidArtifactTypes, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArtifactFilename returns the file name for an artifact type.
|
||||||
|
func ArtifactFilename(t ArtifactType) string {
|
||||||
|
switch t {
|
||||||
|
case ArtifactSpec:
|
||||||
|
return "spec.md"
|
||||||
|
case ArtifactDesign:
|
||||||
|
return "design.md"
|
||||||
|
case ArtifactTasks:
|
||||||
|
return "tasks.md"
|
||||||
|
case ArtifactQAPlan:
|
||||||
|
return "qa-plan.md"
|
||||||
|
case ArtifactReview:
|
||||||
|
return "review.md"
|
||||||
|
case ArtifactAudit:
|
||||||
|
return "audit.md"
|
||||||
|
case ArtifactQAResults:
|
||||||
|
return "qa-results.md"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArtifactStatus tracks the approval state of an artifact.
|
||||||
|
type ArtifactStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusPending ArtifactStatus = "pending"
|
||||||
|
StatusDraft ArtifactStatus = "draft"
|
||||||
|
StatusApproved ArtifactStatus = "approved"
|
||||||
|
StatusRejected ArtifactStatus = "rejected"
|
||||||
|
StatusNeedsFix ArtifactStatus = "needs_fix"
|
||||||
|
StatusPassed ArtifactStatus = "passed"
|
||||||
|
StatusFailed ArtifactStatus = "failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ActionType identifies what the classifier recommends.
|
||||||
|
type ActionType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ActionBlocked ActionType = "BLOCKED"
|
||||||
|
ActionAwaitApproval ActionType = "AWAIT_APPROVAL"
|
||||||
|
ActionTransition ActionType = "TRANSITION"
|
||||||
|
ActionCreateSpec ActionType = "CREATE_SPEC"
|
||||||
|
ActionCreateDesign ActionType = "CREATE_DESIGN"
|
||||||
|
ActionCreateTasks ActionType = "CREATE_TASKS"
|
||||||
|
ActionCreateQAPlan ActionType = "CREATE_QA_PLAN"
|
||||||
|
ActionCreateBranch ActionType = "CREATE_BRANCH"
|
||||||
|
ActionImplementTask ActionType = "IMPLEMENT_TASK"
|
||||||
|
ActionReviewCode ActionType = "REVIEW_CODE"
|
||||||
|
ActionFixReviewIssues ActionType = "FIX_REVIEW_ISSUES"
|
||||||
|
ActionAuditCode ActionType = "AUDIT_CODE"
|
||||||
|
ActionRemediateAudit ActionType = "REMEDIATE_AUDIT"
|
||||||
|
ActionRunQA ActionType = "RUN_QA"
|
||||||
|
ActionFixQAFailures ActionType = "FIX_QA_FAILURES"
|
||||||
|
ActionMergeFeature ActionType = "MERGE_FEATURE"
|
||||||
|
ActionArchive ActionType = "ARCHIVE"
|
||||||
|
ActionIdle ActionType = "IDLE"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TaskStatus tracks the state of an implementation task.
|
||||||
|
type TaskStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TaskPending TaskStatus = "pending"
|
||||||
|
TaskInProgress TaskStatus = "in_progress"
|
||||||
|
TaskComplete TaskStatus = "complete"
|
||||||
|
TaskBlocked TaskStatus = "blocked"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PhaseTransition records when a feature moved between phases.
|
||||||
|
type PhaseTransition struct {
|
||||||
|
Phase FeaturePhase `yaml:"phase" json:"phase"`
|
||||||
|
Entered time.Time `yaml:"entered" json:"entered"`
|
||||||
|
Exited *time.Time `yaml:"exited,omitempty" json:"exited,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dependencies tracks what a feature depends on.
|
||||||
|
type Dependencies struct {
|
||||||
|
Features []string `yaml:"features,omitempty" json:"features,omitempty"`
|
||||||
|
Patterns []string `yaml:"patterns,omitempty" json:"patterns,omitempty"`
|
||||||
|
}
|
||||||
259
internal/service/sdlc_service.go
Normal file
259
internal/service/sdlc_service.go
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/orchard9/rdev/internal/domain"
|
||||||
|
"github.com/orchard9/rdev/internal/port"
|
||||||
|
"github.com/orchard9/rdev/internal/sdlc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SDLCService provides SDLC operations for projects.
|
||||||
|
// It resolves project IDs to pod names and delegates to the SDLCExecutor.
|
||||||
|
type SDLCService struct {
|
||||||
|
sdlcExec port.SDLCExecutor
|
||||||
|
projectRepo port.ProjectRepository
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// SDLCServiceConfig configures the SDLC service.
|
||||||
|
type SDLCServiceConfig struct {
|
||||||
|
Logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSDLCService creates a new SDLC service.
|
||||||
|
func NewSDLCService(sdlcExec port.SDLCExecutor, projectRepo port.ProjectRepository, cfg SDLCServiceConfig) *SDLCService {
|
||||||
|
logger := cfg.Logger
|
||||||
|
if logger == nil {
|
||||||
|
logger = slog.Default()
|
||||||
|
}
|
||||||
|
return &SDLCService{
|
||||||
|
sdlcExec: sdlcExec,
|
||||||
|
projectRepo: projectRepo,
|
||||||
|
logger: logger.With("component", "sdlc-service"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveProjectPod looks up a project and returns its pod name.
|
||||||
|
func (s *SDLCService) resolveProjectPod(ctx context.Context, projectID string) (string, error) {
|
||||||
|
project, err := s.projectRepo.Get(ctx, domain.ProjectID(projectID))
|
||||||
|
if err != nil {
|
||||||
|
return "", domain.ErrProjectNotFound
|
||||||
|
}
|
||||||
|
if project.PodName == "" {
|
||||||
|
return "", fmt.Errorf("project %s has no pod", projectID)
|
||||||
|
}
|
||||||
|
return project.PodName, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetState returns the global SDLC state for a project.
|
||||||
|
func (s *SDLCService) GetState(ctx context.Context, projectID string) (*sdlc.State, error) {
|
||||||
|
podName, err := s.resolveProjectPod(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.sdlcExec.GetState(ctx, podName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNext returns the classifier's recommendation for the next action.
|
||||||
|
func (s *SDLCService) GetNext(ctx context.Context, projectID, feature string) (*sdlc.Classification, error) {
|
||||||
|
podName, err := s.resolveProjectPod(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.sdlcExec.GetNext(ctx, podName, feature)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListFeatures returns all features in a project.
|
||||||
|
func (s *SDLCService) ListFeatures(ctx context.Context, projectID string) ([]*sdlc.Feature, error) {
|
||||||
|
podName, err := s.resolveProjectPod(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.sdlcExec.ListFeatures(ctx, podName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFeature returns a single feature by slug.
|
||||||
|
func (s *SDLCService) GetFeature(ctx context.Context, projectID, slug string) (*sdlc.Feature, error) {
|
||||||
|
podName, err := s.resolveProjectPod(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.sdlcExec.GetFeature(ctx, podName, slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateFeature creates a new feature.
|
||||||
|
func (s *SDLCService) CreateFeature(ctx context.Context, projectID, slug, title string) (*sdlc.Feature, error) {
|
||||||
|
podName, err := s.resolveProjectPod(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
f, err := s.sdlcExec.CreateFeature(ctx, podName, slug, title)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.logger.Info("feature created", "project", projectID, "feature", slug)
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TransitionFeature moves a feature to the specified phase.
|
||||||
|
func (s *SDLCService) TransitionFeature(ctx context.Context, projectID, slug string, phase sdlc.FeaturePhase) error {
|
||||||
|
podName, err := s.resolveProjectPod(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.sdlcExec.TransitionFeature(ctx, podName, slug, phase); err != nil {
|
||||||
|
s.logger.Error("transition feature failed", "project", projectID, "feature", slug, "phase", string(phase), "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.logger.Info("feature transitioned", "project", projectID, "feature", slug, "phase", string(phase))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockFeature adds a blocker reason to a feature.
|
||||||
|
func (s *SDLCService) BlockFeature(ctx context.Context, projectID, slug, reason string) error {
|
||||||
|
podName, err := s.resolveProjectPod(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.sdlcExec.BlockFeature(ctx, podName, slug, reason); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.logger.Info("feature blocked", "project", projectID, "feature", slug, "reason", reason)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnblockFeature removes all blockers from a feature.
|
||||||
|
func (s *SDLCService) UnblockFeature(ctx context.Context, projectID, slug string) error {
|
||||||
|
podName, err := s.resolveProjectPod(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.sdlcExec.UnblockFeature(ctx, podName, slug); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.logger.Info("feature unblocked", "project", projectID, "feature", slug)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteFeature removes a feature entirely.
|
||||||
|
func (s *SDLCService) DeleteFeature(ctx context.Context, projectID, slug string) error {
|
||||||
|
podName, err := s.resolveProjectPod(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.sdlcExec.DeleteFeature(ctx, podName, slug); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.logger.Info("feature deleted", "project", projectID, "feature", slug)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetArtifactStatus returns artifact statuses for a feature.
|
||||||
|
func (s *SDLCService) GetArtifactStatus(ctx context.Context, projectID, slug string) (map[sdlc.ArtifactType]*sdlc.Artifact, error) {
|
||||||
|
podName, err := s.resolveProjectPod(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.sdlcExec.GetArtifactStatus(ctx, podName, slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApproveArtifact approves a feature artifact.
|
||||||
|
func (s *SDLCService) ApproveArtifact(ctx context.Context, projectID, slug string, artType sdlc.ArtifactType) error {
|
||||||
|
podName, err := s.resolveProjectPod(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.sdlcExec.ApproveArtifact(ctx, podName, slug, artType); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.logger.Info("artifact approved", "project", projectID, "feature", slug, "artifact", string(artType))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RejectArtifact rejects a feature artifact.
|
||||||
|
func (s *SDLCService) RejectArtifact(ctx context.Context, projectID, slug string, artType sdlc.ArtifactType) error {
|
||||||
|
podName, err := s.resolveProjectPod(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.sdlcExec.RejectArtifact(ctx, podName, slug, artType); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.logger.Info("artifact rejected", "project", projectID, "feature", slug, "artifact", string(artType))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTasks returns all tasks for a feature.
|
||||||
|
func (s *SDLCService) ListTasks(ctx context.Context, projectID, slug string) ([]sdlc.Task, error) {
|
||||||
|
podName, err := s.resolveProjectPod(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.sdlcExec.ListTasks(ctx, podName, slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddTask adds a new task to a feature.
|
||||||
|
func (s *SDLCService) AddTask(ctx context.Context, projectID, slug, title string) (*sdlc.Task, error) {
|
||||||
|
podName, err := s.resolveProjectPod(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.sdlcExec.AddTask(ctx, podName, slug, title)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartTask marks a task as in-progress.
|
||||||
|
func (s *SDLCService) StartTask(ctx context.Context, projectID, slug, taskID string) error {
|
||||||
|
podName, err := s.resolveProjectPod(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.sdlcExec.StartTask(ctx, podName, slug, taskID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompleteTask marks a task as complete.
|
||||||
|
func (s *SDLCService) CompleteTask(ctx context.Context, projectID, slug, taskID string) error {
|
||||||
|
podName, err := s.resolveProjectPod(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.sdlcExec.CompleteTask(ctx, podName, slug, taskID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockTask marks a task as blocked.
|
||||||
|
func (s *SDLCService) BlockTask(ctx context.Context, projectID, slug, taskID string) error {
|
||||||
|
podName, err := s.resolveProjectPod(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.sdlcExec.BlockTask(ctx, podName, slug, taskID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryBlocked returns all blocked features in a project.
|
||||||
|
func (s *SDLCService) QueryBlocked(ctx context.Context, projectID string) ([]port.BlockedInfo, error) {
|
||||||
|
podName, err := s.resolveProjectPod(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.sdlcExec.QueryBlocked(ctx, podName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryReady returns features ready for work in a project.
|
||||||
|
func (s *SDLCService) QueryReady(ctx context.Context, projectID string) ([]port.ReadyInfo, error) {
|
||||||
|
podName, err := s.resolveProjectPod(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.sdlcExec.QueryReady(ctx, podName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryNeedsApproval returns features awaiting approval in a project.
|
||||||
|
func (s *SDLCService) QueryNeedsApproval(ctx context.Context, projectID string) ([]port.ApprovalInfo, error) {
|
||||||
|
podName, err := s.resolveProjectPod(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.sdlcExec.QueryNeedsApproval(ctx, podName)
|
||||||
|
}
|
||||||
374
internal/service/sdlc_service_test.go
Normal file
374
internal/service/sdlc_service_test.go
Normal file
@ -0,0 +1,374 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/orchard9/rdev/internal/domain"
|
||||||
|
"github.com/orchard9/rdev/internal/port"
|
||||||
|
"github.com/orchard9/rdev/internal/sdlc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mockSDLCExecutor implements port.SDLCExecutor for testing.
|
||||||
|
type mockSDLCExecutor struct {
|
||||||
|
getStateFn func(ctx context.Context, podName string) (*sdlc.State, error)
|
||||||
|
getNextFn func(ctx context.Context, podName, feature string) (*sdlc.Classification, error)
|
||||||
|
listFeaturesFn func(ctx context.Context, podName string) ([]*sdlc.Feature, error)
|
||||||
|
getFeatureFn func(ctx context.Context, podName, slug string) (*sdlc.Feature, error)
|
||||||
|
createFeatureFn func(ctx context.Context, podName, slug, title string) (*sdlc.Feature, error)
|
||||||
|
transitionFeatureFn func(ctx context.Context, podName, slug string, phase sdlc.FeaturePhase) error
|
||||||
|
blockFeatureFn func(ctx context.Context, podName, slug, reason string) error
|
||||||
|
unblockFeatureFn func(ctx context.Context, podName, slug string) error
|
||||||
|
deleteFeatureFn func(ctx context.Context, podName, slug string) error
|
||||||
|
getArtifactStatusFn func(ctx context.Context, podName, slug string) (map[sdlc.ArtifactType]*sdlc.Artifact, error)
|
||||||
|
approveArtifactFn func(ctx context.Context, podName, slug string, artType sdlc.ArtifactType) error
|
||||||
|
rejectArtifactFn func(ctx context.Context, podName, slug string, artType sdlc.ArtifactType) error
|
||||||
|
listTasksFn func(ctx context.Context, podName, slug string) ([]sdlc.Task, error)
|
||||||
|
addTaskFn func(ctx context.Context, podName, slug, title string) (*sdlc.Task, error)
|
||||||
|
startTaskFn func(ctx context.Context, podName, slug, taskID string) error
|
||||||
|
completeTaskFn func(ctx context.Context, podName, slug, taskID string) error
|
||||||
|
blockTaskFn func(ctx context.Context, podName, slug, taskID string) error
|
||||||
|
queryBlockedFn func(ctx context.Context, podName string) ([]port.BlockedInfo, error)
|
||||||
|
queryReadyFn func(ctx context.Context, podName string) ([]port.ReadyInfo, error)
|
||||||
|
queryNeedsApprFn func(ctx context.Context, podName string) ([]port.ApprovalInfo, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSDLCExecutor) GetState(ctx context.Context, podName string) (*sdlc.State, error) {
|
||||||
|
if m.getStateFn != nil {
|
||||||
|
return m.getStateFn(ctx, podName)
|
||||||
|
}
|
||||||
|
return &sdlc.State{Version: 1}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSDLCExecutor) GetNext(ctx context.Context, podName, feature string) (*sdlc.Classification, error) {
|
||||||
|
if m.getNextFn != nil {
|
||||||
|
return m.getNextFn(ctx, podName, feature)
|
||||||
|
}
|
||||||
|
return &sdlc.Classification{Action: sdlc.ActionIdle}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSDLCExecutor) ListFeatures(ctx context.Context, podName string) ([]*sdlc.Feature, error) {
|
||||||
|
if m.listFeaturesFn != nil {
|
||||||
|
return m.listFeaturesFn(ctx, podName)
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSDLCExecutor) GetFeature(ctx context.Context, podName, slug string) (*sdlc.Feature, error) {
|
||||||
|
if m.getFeatureFn != nil {
|
||||||
|
return m.getFeatureFn(ctx, podName, slug)
|
||||||
|
}
|
||||||
|
return &sdlc.Feature{Slug: slug}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSDLCExecutor) CreateFeature(ctx context.Context, podName, slug, title string) (*sdlc.Feature, error) {
|
||||||
|
if m.createFeatureFn != nil {
|
||||||
|
return m.createFeatureFn(ctx, podName, slug, title)
|
||||||
|
}
|
||||||
|
return &sdlc.Feature{Slug: slug, Title: title}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSDLCExecutor) TransitionFeature(ctx context.Context, podName, slug string, phase sdlc.FeaturePhase) error {
|
||||||
|
if m.transitionFeatureFn != nil {
|
||||||
|
return m.transitionFeatureFn(ctx, podName, slug, phase)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSDLCExecutor) BlockFeature(ctx context.Context, podName, slug, reason string) error {
|
||||||
|
if m.blockFeatureFn != nil {
|
||||||
|
return m.blockFeatureFn(ctx, podName, slug, reason)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSDLCExecutor) UnblockFeature(ctx context.Context, podName, slug string) error {
|
||||||
|
if m.unblockFeatureFn != nil {
|
||||||
|
return m.unblockFeatureFn(ctx, podName, slug)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSDLCExecutor) DeleteFeature(ctx context.Context, podName, slug string) error {
|
||||||
|
if m.deleteFeatureFn != nil {
|
||||||
|
return m.deleteFeatureFn(ctx, podName, slug)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSDLCExecutor) GetArtifactStatus(ctx context.Context, podName, slug string) (map[sdlc.ArtifactType]*sdlc.Artifact, error) {
|
||||||
|
if m.getArtifactStatusFn != nil {
|
||||||
|
return m.getArtifactStatusFn(ctx, podName, slug)
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSDLCExecutor) ApproveArtifact(ctx context.Context, podName, slug string, artType sdlc.ArtifactType) error {
|
||||||
|
if m.approveArtifactFn != nil {
|
||||||
|
return m.approveArtifactFn(ctx, podName, slug, artType)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSDLCExecutor) RejectArtifact(ctx context.Context, podName, slug string, artType sdlc.ArtifactType) error {
|
||||||
|
if m.rejectArtifactFn != nil {
|
||||||
|
return m.rejectArtifactFn(ctx, podName, slug, artType)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSDLCExecutor) ListTasks(ctx context.Context, podName, slug string) ([]sdlc.Task, error) {
|
||||||
|
if m.listTasksFn != nil {
|
||||||
|
return m.listTasksFn(ctx, podName, slug)
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSDLCExecutor) AddTask(ctx context.Context, podName, slug, title string) (*sdlc.Task, error) {
|
||||||
|
if m.addTaskFn != nil {
|
||||||
|
return m.addTaskFn(ctx, podName, slug, title)
|
||||||
|
}
|
||||||
|
return &sdlc.Task{ID: "task-001", Title: title}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSDLCExecutor) StartTask(ctx context.Context, podName, slug, taskID string) error {
|
||||||
|
if m.startTaskFn != nil {
|
||||||
|
return m.startTaskFn(ctx, podName, slug, taskID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSDLCExecutor) CompleteTask(ctx context.Context, podName, slug, taskID string) error {
|
||||||
|
if m.completeTaskFn != nil {
|
||||||
|
return m.completeTaskFn(ctx, podName, slug, taskID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSDLCExecutor) BlockTask(ctx context.Context, podName, slug, taskID string) error {
|
||||||
|
if m.blockTaskFn != nil {
|
||||||
|
return m.blockTaskFn(ctx, podName, slug, taskID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSDLCExecutor) QueryBlocked(ctx context.Context, podName string) ([]port.BlockedInfo, error) {
|
||||||
|
if m.queryBlockedFn != nil {
|
||||||
|
return m.queryBlockedFn(ctx, podName)
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSDLCExecutor) QueryReady(ctx context.Context, podName string) ([]port.ReadyInfo, error) {
|
||||||
|
if m.queryReadyFn != nil {
|
||||||
|
return m.queryReadyFn(ctx, podName)
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSDLCExecutor) QueryNeedsApproval(ctx context.Context, podName string) ([]port.ApprovalInfo, error) {
|
||||||
|
if m.queryNeedsApprFn != nil {
|
||||||
|
return m.queryNeedsApprFn(ctx, podName)
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mockProjectRepo implements port.ProjectRepository for testing.
|
||||||
|
type mockProjectRepo struct {
|
||||||
|
projects map[domain.ProjectID]*domain.Project
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMockProjectRepo(projects ...*domain.Project) *mockProjectRepo {
|
||||||
|
m := &mockProjectRepo{projects: make(map[domain.ProjectID]*domain.Project)}
|
||||||
|
for _, p := range projects {
|
||||||
|
m.projects[p.ID] = p
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockProjectRepo) Get(_ context.Context, id domain.ProjectID) (*domain.Project, error) {
|
||||||
|
p, ok := m.projects[id]
|
||||||
|
if !ok {
|
||||||
|
return nil, domain.ErrProjectNotFound
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockProjectRepo) List(_ context.Context) ([]domain.Project, error) { return nil, nil }
|
||||||
|
func (m *mockProjectRepo) Exists(_ context.Context, id domain.ProjectID) (bool, error) {
|
||||||
|
_, ok := m.projects[id]
|
||||||
|
return ok, nil
|
||||||
|
}
|
||||||
|
func (m *mockProjectRepo) Register(_ context.Context, _ *domain.Project) error { return nil }
|
||||||
|
func (m *mockProjectRepo) Unregister(_ context.Context, _ domain.ProjectID) error { return nil }
|
||||||
|
func (m *mockProjectRepo) RefreshStatus(_ context.Context) error { return nil }
|
||||||
|
|
||||||
|
func newTestService(exec port.SDLCExecutor, repo *mockProjectRepo) *SDLCService {
|
||||||
|
return NewSDLCService(exec, repo, SDLCServiceConfig{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSDLCService_GetState(t *testing.T) {
|
||||||
|
repo := newMockProjectRepo(&domain.Project{
|
||||||
|
ID: "myproj",
|
||||||
|
PodName: "myproj-pod",
|
||||||
|
})
|
||||||
|
exec := &mockSDLCExecutor{
|
||||||
|
getStateFn: func(_ context.Context, podName string) (*sdlc.State, error) {
|
||||||
|
if podName != "myproj-pod" {
|
||||||
|
t.Errorf("expected podName myproj-pod, got %s", podName)
|
||||||
|
}
|
||||||
|
return &sdlc.State{Version: 1}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := newTestService(exec, repo)
|
||||||
|
|
||||||
|
state, err := svc.GetState(context.Background(), "myproj")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if state.Version != 1 {
|
||||||
|
t.Errorf("expected version 1, got %d", state.Version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSDLCService_ProjectNotFound(t *testing.T) {
|
||||||
|
repo := newMockProjectRepo() // empty
|
||||||
|
exec := &mockSDLCExecutor{}
|
||||||
|
svc := newTestService(exec, repo)
|
||||||
|
|
||||||
|
_, err := svc.GetState(context.Background(), "nonexistent")
|
||||||
|
if !errors.Is(err, domain.ErrProjectNotFound) {
|
||||||
|
t.Errorf("expected ErrProjectNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSDLCService_TransitionFeature(t *testing.T) {
|
||||||
|
var calledSlug string
|
||||||
|
var calledPhase sdlc.FeaturePhase
|
||||||
|
|
||||||
|
repo := newMockProjectRepo(&domain.Project{
|
||||||
|
ID: "myproj",
|
||||||
|
PodName: "myproj-pod",
|
||||||
|
})
|
||||||
|
exec := &mockSDLCExecutor{
|
||||||
|
transitionFeatureFn: func(_ context.Context, _ string, slug string, phase sdlc.FeaturePhase) error {
|
||||||
|
calledSlug = slug
|
||||||
|
calledPhase = phase
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := newTestService(exec, repo)
|
||||||
|
|
||||||
|
err := svc.TransitionFeature(context.Background(), "myproj", "auth-flow", sdlc.PhaseSpecified)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if calledSlug != "auth-flow" {
|
||||||
|
t.Errorf("expected slug auth-flow, got %s", calledSlug)
|
||||||
|
}
|
||||||
|
if calledPhase != sdlc.PhaseSpecified {
|
||||||
|
t.Errorf("expected phase specified, got %s", calledPhase)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSDLCService_TransitionFeature_Error(t *testing.T) {
|
||||||
|
repo := newMockProjectRepo(&domain.Project{
|
||||||
|
ID: "myproj",
|
||||||
|
PodName: "myproj-pod",
|
||||||
|
})
|
||||||
|
exec := &mockSDLCExecutor{
|
||||||
|
transitionFeatureFn: func(_ context.Context, _, _ string, _ sdlc.FeaturePhase) error {
|
||||||
|
return sdlc.ErrInvalidTransition
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := newTestService(exec, repo)
|
||||||
|
|
||||||
|
err := svc.TransitionFeature(context.Background(), "myproj", "auth-flow", sdlc.PhaseReview)
|
||||||
|
if !errors.Is(err, sdlc.ErrInvalidTransition) {
|
||||||
|
t.Errorf("expected ErrInvalidTransition, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSDLCService_CreateFeature(t *testing.T) {
|
||||||
|
repo := newMockProjectRepo(&domain.Project{
|
||||||
|
ID: "myproj",
|
||||||
|
PodName: "myproj-pod",
|
||||||
|
})
|
||||||
|
exec := &mockSDLCExecutor{}
|
||||||
|
svc := newTestService(exec, repo)
|
||||||
|
|
||||||
|
f, err := svc.CreateFeature(context.Background(), "myproj", "new-feature", "New Feature")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if f.Slug != "new-feature" {
|
||||||
|
t.Errorf("expected slug new-feature, got %s", f.Slug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSDLCService_ApproveArtifact(t *testing.T) {
|
||||||
|
var calledArtType sdlc.ArtifactType
|
||||||
|
repo := newMockProjectRepo(&domain.Project{
|
||||||
|
ID: "myproj",
|
||||||
|
PodName: "myproj-pod",
|
||||||
|
})
|
||||||
|
exec := &mockSDLCExecutor{
|
||||||
|
approveArtifactFn: func(_ context.Context, _, _ string, artType sdlc.ArtifactType) error {
|
||||||
|
calledArtType = artType
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := newTestService(exec, repo)
|
||||||
|
|
||||||
|
err := svc.ApproveArtifact(context.Background(), "myproj", "auth-flow", sdlc.ArtifactSpec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if calledArtType != sdlc.ArtifactSpec {
|
||||||
|
t.Errorf("expected artifact type spec, got %s", calledArtType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSDLCService_QueryBlocked(t *testing.T) {
|
||||||
|
repo := newMockProjectRepo(&domain.Project{
|
||||||
|
ID: "myproj",
|
||||||
|
PodName: "myproj-pod",
|
||||||
|
})
|
||||||
|
exec := &mockSDLCExecutor{
|
||||||
|
queryBlockedFn: func(_ context.Context, _ string) ([]port.BlockedInfo, error) {
|
||||||
|
return []port.BlockedInfo{
|
||||||
|
{Slug: "auth", Phase: "implementation", Blockers: []string{"needs API key"}},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := newTestService(exec, repo)
|
||||||
|
|
||||||
|
blocked, err := svc.QueryBlocked(context.Background(), "myproj")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(blocked) != 1 {
|
||||||
|
t.Fatalf("expected 1 blocked item, got %d", len(blocked))
|
||||||
|
}
|
||||||
|
if blocked[0].Slug != "auth" {
|
||||||
|
t.Errorf("expected slug auth, got %s", blocked[0].Slug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSDLCService_AddTask(t *testing.T) {
|
||||||
|
repo := newMockProjectRepo(&domain.Project{
|
||||||
|
ID: "myproj",
|
||||||
|
PodName: "myproj-pod",
|
||||||
|
})
|
||||||
|
exec := &mockSDLCExecutor{}
|
||||||
|
svc := newTestService(exec, repo)
|
||||||
|
|
||||||
|
task, err := svc.AddTask(context.Background(), "myproj", "auth-flow", "Add login form")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if task.Title != "Add login form" {
|
||||||
|
t.Errorf("expected title 'Add login form', got %s", task.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user